Feature history on the left side, with contiguous folders (#449)

The feature history can sit in a panel on the left, with folders that hold one unbroken run of features, drag and drop as one undo step, and a resizable divider. Timeline folders save under format 17.
This commit is contained in:
Alexander Nutz authored and GitHub committed 2026-10-13 13:01:31 -04:00
1 parent be55a49b04
commit a8b3b21fd0
17 files changed
+2336 -37

No files matched your search

+1 -1
View File
@@ -49,7 +49,7 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
| M3 | General fillets and chamfers (curved edges, chains, vertex blends); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks | in progress | 30 |
| M4 | Files: STEP import (done — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), constant-section parametric mesh reconstruction, DXF sketches, OBJ import | in progress | 6 |
| M5 | Persistent naming of faces and edges (docs/book/src/dev/naming.md): faces named from their exact kernel-reported history (sketch curve sides, caps, revolve sections, primitive sides, blends, shell inner walls, pattern/mirror copies; split pieces told apart by the faces around them, lumps of a cut body named after their faces), 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. kernel-reported face history: done (merged faces answer to every name; operations without history yet fall back to geometric names, flagged unverified; format 14 keeps older designs' geometric names). Left: history through sheet metal, plastic, surface and emboss features; FaceSource certificates through history | in progress | 20 |
| 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; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names), 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); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; 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, dependency-checked single and multi-item reorder, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names; optional compact named left feature history with rollback bar, multi-selection and persistent folders above the parts browser), 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); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 |
| M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), 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. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 |
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis. Proposed, in order: surface Extrude/Revolve/Sweep/Loft from open and closed curves (#757), Offset surface (#758), Unstitch and Reverse Normal (#766), Delete Face without healing (#768); Extend curved faces and edges (#760), mutual trim and trim by projected curves (#767), Untrim (#761), Thicken every surface kind (#765); Ruled (#762), Boundary surface and curve networks with G0/G1/G2 (#763), Patch with G1/G2 and general loops (#759), Blend surface (#764), sweep rails, twist and scale (#771, loft rails #201), fillet between surface bodies (#769); continuity analysis, surface curvature combs and isocurves (#770) | deferred | — |
| M9 | Components and assemblies: 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. Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. 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 |
+111
View File
@@ -1802,6 +1802,10 @@ pub struct Document {
/// Browser groups (folders of bodies, sketches or construction planes).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub browser_groups: Vec<BrowserGroup>,
/// Named folders used to organise features in the timeline. They do not affect evaluation
/// order; feature ids remain the source of truth when features are renamed or reordered.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub timeline_groups: Vec<TimelineGroup>,
/// Browser item order per component folder (`"<component>/<folder>"` → item keys).
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub browser_order: std::collections::BTreeMap<String, Vec<String>>,
@@ -1868,6 +1872,66 @@ pub struct BrowserGroup {
pub items: Vec<String>,
}
/// A saved feature-history folder. Folders may nest, and a feature can belong to at most one folder.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct TimelineGroup {
pub id: u64,
pub name: String,
/// The containing feature folder. `None` places the folder at the timeline root.
#[serde(default)]
pub parent: Option<u64>,
#[serde(default)]
pub features: Vec<u64>,
}
/// The first feature-history folder whose own and nested features do not form one
/// uninterrupted run in the timeline.
pub fn non_contiguous_timeline_group(features: &[Feature], groups: &[TimelineGroup]) -> Option<u64> {
let positions: std::collections::BTreeMap<u64, usize> = features.iter().enumerate().map(|(index, feature)| (feature.id, index)).collect();
let mut stats = std::collections::BTreeMap::new();
let mut children = std::collections::BTreeMap::new();
for group in groups {
let mut count = 0usize;
let mut first = usize::MAX;
let mut last = 0usize;
for index in group.features.iter().filter_map(|feature| positions.get(feature)) {
count = count.saturating_add(1);
first = first.min(*index);
last = last.max(*index);
}
stats.insert(group.id, (count, first, last));
children.entry(group.id).or_insert(0usize);
if let Some(parent) = group.parent {
let child_count = children.entry(parent).or_insert(0);
*child_count = child_count.saturating_add(1);
}
}
let parents: std::collections::BTreeMap<u64, Option<u64>> = groups.iter().map(|group| (group.id, group.parent)).collect();
let mut pending: Vec<u64> = groups.iter().filter(|group| children.get(&group.id) == Some(&0)).map(|group| group.id).collect();
while let Some(id) = pending.pop() {
let (count, first, last) = stats.get(&id).copied().unwrap_or_default();
if count > 0 && last.saturating_sub(first).saturating_add(1) != count {
return Some(id);
}
if let Some(parent) = parents.get(&id).copied().flatten() {
if let Some((parent_count, parent_first, parent_last)) = stats.get_mut(&parent) {
*parent_count = parent_count.saturating_add(count);
if count > 0 {
*parent_first = (*parent_first).min(first);
*parent_last = (*parent_last).max(last);
}
}
if let Some(remaining) = children.get_mut(&parent) {
*remaining = remaining.saturating_sub(1);
if *remaining == 0 {
pending.push(parent);
}
}
}
}
None
}
fn default_format() -> String {
crate::format::format_string(crate::format::FORMAT)
}
@@ -1908,6 +1972,7 @@ impl Document {
canvases: Vec::new(),
param_comments: Default::default(),
browser_groups: Vec::new(),
timeline_groups: Vec::new(),
browser_order: Default::default(),
named_views: Vec::new(),
visibility: Visibility::default(),
@@ -2092,6 +2157,40 @@ impl Document {
}
}
/// The deepest timeline folder spanning the insertion slot between `marker - 1` and
/// `marker`. A slot at a folder's outer boundary is not inside that folder.
fn timeline_group_at_insertion(&self, marker: usize) -> Option<u64> {
let before = self.features.get(marker.checked_sub(1)?)?.id;
let after = self.features.get(marker)?.id;
let direct = |feature| self.timeline_groups.iter().find(|group| group.features.contains(&feature)).map(|group| group.id);
let parents: std::collections::BTreeMap<u64, Option<u64>> = self.timeline_groups.iter().map(|group| (group.id, group.parent)).collect();
let mut before_ancestors = std::collections::BTreeSet::new();
let mut current = direct(before);
let mut steps = 0usize;
while let Some(id) = current {
if !before_ancestors.insert(id) || steps >= self.timeline_groups.len() {
return None;
}
current = parents.get(&id).copied().flatten();
steps = steps.saturating_add(1);
}
current = direct(after);
steps = 0;
while let Some(id) = current {
if before_ancestors.contains(&id) {
return Some(id);
}
if steps >= self.timeline_groups.len() {
return None;
}
current = parents.get(&id).copied().flatten();
steps = steps.saturating_add(1);
}
None
}
/// Insert a feature at the marker (or the end) and return its id.
pub fn add_feature(&mut self, kind: FeatureKind, name: Option<&str>) -> Result<u64> {
if self.features.len() >= MAX_FEATURES {
@@ -2123,6 +2222,7 @@ impl Document {
shell_piece_order: false,
kind,
};
let insertion_group = self.marker.and_then(|marker| self.timeline_group_at_insertion(marker));
match self.marker {
Some(m) if m < self.features.len() => {
self.features.insert(m, f);
@@ -2130,6 +2230,14 @@ impl Document {
}
_ => self.features.push(f),
}
if let Some(group_id) = insertion_group {
let positions: std::collections::BTreeMap<u64, usize> =
self.features.iter().enumerate().map(|(index, feature)| (feature.id, index)).collect();
if let Some(group) = self.timeline_groups.iter_mut().find(|group| group.id == group_id) {
group.features.push(id);
group.features.sort_by_key(|feature| positions.get(feature).copied().unwrap_or(usize::MAX));
}
}
self.name_feature_inputs(id);
Ok(id)
}
@@ -2144,6 +2252,9 @@ impl Document {
.rules
.retain(|rule| !rule.references_features(&gone) && !rule.parameter_references().iter().any(|name| gone_params.contains(name)));
self.features.retain(|f| !gone.contains(&f.id));
for group in &mut self.timeline_groups {
group.features.retain(|feature| !gone.contains(feature));
}
// Configuration columns of deleted features go with them.
let keep: Vec<bool> = self
.configs
+37
View File
@@ -91,6 +91,9 @@
//! edited.
//! A fillet's `fit` can be `taper` (the radius follows the room along each edge it doesn't
//! fit). Fillets with `exact` or `reduce` save as before; 0.8.0 would refuse the unknown fit.
//! Feature-history folders are also first saved in 17 (`timeline_groups`: stable folder id,
//! name, optional parent folder and feature ids). Version 16 designs have no saved timeline
//! folders and open with the empty default.
//! A fillet's `type` can be `spline` (the radius along each edge a B-spline: `start`, `knots`,
//! and the control radii after `radius` in `radii`). Other fillets save as before; 0.8.0 would
//! refuse the unknown type.
@@ -367,9 +370,43 @@ pub fn read(s: &str) -> Result<Document> {
if d.features.len() > crate::document::MAX_FEATURES
|| d.params.len() > crate::document::MAX_PARAMS
|| d.components.len() > crate::document::MAX_FEATURES
|| d.timeline_groups.len() > crate::document::MAX_FEATURES
|| d.timeline_groups.iter().any(|group| group.features.len() > crate::document::MAX_FEATURES)
{
return Err(DocError::Invalid("document too large".into()));
}
let feature_ids: std::collections::BTreeSet<_> = d.features.iter().map(|feature| feature.id).collect();
let mut group_ids = std::collections::BTreeSet::new();
let mut grouped_features = std::collections::BTreeSet::new();
for group in &d.timeline_groups {
if group.name.trim().is_empty() || group.name.len() > 128 || !group_ids.insert(group.id) {
return Err(DocError::Invalid("damaged design: invalid timeline folder".into()));
}
if group.parent.is_some_and(|parent| parent == group.id) {
return Err(DocError::Invalid("damaged design: timeline folder cannot contain itself".into()));
}
if group.features.iter().any(|feature| !feature_ids.contains(feature) || !grouped_features.insert(*feature)) {
return Err(DocError::Invalid("damaged design: invalid feature in a timeline folder".into()));
}
}
for group in &d.timeline_groups {
if let Some(parent) = group.parent {
if !group_ids.contains(&parent) {
return Err(DocError::Invalid("damaged design: timeline folder has an unknown parent".into()));
}
let mut seen = std::collections::BTreeSet::new();
let mut current = Some(parent);
while let Some(id) = current {
if !seen.insert(id) {
return Err(DocError::Invalid("damaged design: cyclic timeline folders".into()));
}
current = d.timeline_groups.iter().find(|candidate| candidate.id == id).and_then(|candidate| candidate.parent);
}
}
}
if let Some(id) = crate::non_contiguous_timeline_group(&d.features, &d.timeline_groups) {
return Err(DocError::Invalid(format!("damaged design: timeline folder {id} is not contiguous")));
}
for o in &d.occurrences {
if !rigid(&o.transform) {
return Err(DocError::Invalid(format!("damaged design: occurrence `{}` has an invalid placement", o.name)));
+19
View File
@@ -386,6 +386,25 @@ fn timeline_clones_share_meshes_even_when_cloned_before_tessellation() {
assert!(!a.mesh().triangles.is_empty());
}
#[test]
fn feature_inserted_at_a_marker_inside_a_timeline_folder_joins_it() {
let mut doc = Document::new("folder insertion");
let first = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: Sketch::new() }, Some("First")).unwrap();
let second =
doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: Sketch::new() }, Some("Second")).unwrap();
doc.timeline_groups.push(TimelineGroup { id: 100, name: "Pair".into(), parent: None, features: vec![first, second] });
doc.marker = Some(1);
let inserted =
doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: Sketch::new() }, Some("Inserted")).unwrap();
assert_eq!(doc.features.iter().map(|feature| feature.id).collect::<Vec<_>>(), vec![first, inserted, second]);
assert_eq!(doc.timeline_groups[0].features, vec![first, inserted, second]);
assert_eq!(doc.marker, Some(2));
assert!(non_contiguous_timeline_group(&doc.features, &doc.timeline_groups).is_none());
assert!(Document::from_json(&doc.to_json()).is_ok(), "the inserted feature must leave a reopenable design");
}
/// A construction plane that is its own side resolves through the model and back into the
/// document. The depth count has to survive that hop, or the recursion overflows the stack and
/// aborts the process, which no panic guard can intercept.
+22
View File
@@ -211,6 +211,28 @@ mod tests {
assert!(t.execute("timeline.paste", &json!({})).is_err());
}
#[test]
fn paste_at_a_marker_inside_a_timeline_folder_joins_the_folder() {
let mut s = Session::default();
for (name, body, x) in [("First", "FirstBody", 0), ("Second", "SecondBody", 20), ("Source", "SourceBody", 40)] {
run(
&mut s,
"solid.box",
json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0], "operation": "new_body", "name": name, "body_name": body}),
);
}
let group = run(&mut s, "timeline.group", json!({"name": "Pair", "features": ["First", "Second"]}))["group"].as_u64().unwrap();
run(&mut s, "timeline.copy", json!({"features": ["Source"]}));
run(&mut s, "timeline.roll_to", json!({"position": 1}));
let pasted = run(&mut s, "timeline.paste", json!({}))["pasted"][0]["id"].as_u64().unwrap();
let folder = s.doc.timeline_groups.iter().find(|folder| folder.id == group).unwrap();
let first = s.doc.find_feature("First").unwrap().id;
let second = s.doc.find_feature("Second").unwrap().id;
assert_eq!(folder.features, vec![first, pasted, second]);
assert!(solidcraft_doc::Document::from_json(&s.doc.to_json()).is_ok(), "pasting inside a folder must leave a reopenable design");
}
#[test]
fn pasted_fillet_rounds_the_copied_body_it_names() {
let mut s = Session::default();
+369
View File
@@ -25,11 +25,28 @@ pub static COMMANDS: &[CommandSpec] = &[
.key("Delete")
.params("features: [id or name] (a sketch takes its dependent features with it) → deleted: [ids]; failing?: [{feature, error}] (later features that worked and now fail)"),
CommandSpec::new("timeline.rename", "Rename", rename).params("feature: id|name, name (references to it follow)"),
CommandSpec::new("timeline.group", "New Feature Folder", group)
.icon("folder")
.doc_only()
.params("name?; features?: [id|name]; parent?: folder id (features leave any other timeline folder)"),
CommandSpec::new("timeline.ungroup", "Remove Feature Folders", ungroup)
.doc_only()
.params("group: id | groups: [ids] (features remain in the timeline)"),
CommandSpec::new("timeline.rename_group", "Rename Feature Folder", rename_group).doc_only().params("group: id, name"),
CommandSpec::new("timeline.move_to_group", "Move Features to Folder", move_to_group)
.doc_only()
.params("features?: [id|name], groups?: [ids], group?: id (omit or null to move to the timeline root)"),
CommandSpec::new("timeline.move_many", "Move Timeline Items", move_many).params(
"features?: [id|name], groups?: [folder ids], group?: folder id, position: insertion slot; atomically changes folder membership and moves all selected contents as one block",
),
CommandSpec::new("timeline.rollback", "Move Timeline Marker", rollback).params("position: number of features to keep active (omit = end)"),
CommandSpec::new("timeline.roll_to", "Roll History Marker", rollback)
.params("position: number of features to keep active (omit = end) | feature: id|name (the marker goes right after it)"),
CommandSpec::new("timeline.reorder", "Reorder Feature", reorder)
.params("feature: id|name, position: its new index; refused when a feature that worked would fail"),
CommandSpec::new("timeline.reorder_many", "Reorder Features", reorder_many).params(
"features: [id|name], position: insertion slot in the current history; selected features keep their relative order; refused when a feature that worked would fail",
),
CommandSpec::new("timeline.redefine", "Edit Feature", redefine)
.params("feature: id|name, command: the feature's command id, params: its parameters (the feature is rebuilt in place)"),
CommandSpec::new("timeline.dependents", "Feature Dependents", dependents)
@@ -187,6 +204,302 @@ fn rename(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"feature": id, "name": name}))
}
fn timeline_group_id(s: &Session, p: &Value, cmd: &str) -> Result<u64> {
timeline_group_id_field(s, p, "group", cmd)
}
fn timeline_group_id_field(s: &Session, p: &Value, field: &str, cmd: &str) -> Result<u64> {
let id = p.get(field).and_then(Value::as_u64).ok_or_else(|| bad(cmd, format!("`{field}` must be a timeline folder id")))?;
if s.doc.timeline_groups.iter().any(|group| group.id == id) { Ok(id) } else { Err(bad(cmd, format!("no timeline folder {id}"))) }
}
fn group_features(s: &Session, p: &Value, required: bool, cmd: &str) -> Result<Vec<u64>> {
let values = match p.get("features") {
Some(Value::Array(values)) => values,
None if !required => return Ok(Vec::new()),
_ => return Err(bad(cmd, "`features` must be a list of feature ids or names")),
};
if values.is_empty() && required {
return Err(bad(cmd, "`features` must list at least one feature"));
}
if values.len() > 10_000 {
return Err(bad(cmd, "too many features"));
}
let mut ids = Vec::new();
for value in values {
let id = feature_id(s, Some(value), cmd)?;
if !ids.contains(&id) {
ids.push(id);
}
}
Ok(ids)
}
fn group_ids(s: &Session, p: &Value, required: bool, cmd: &str) -> Result<Vec<u64>> {
let values = match p.get("groups") {
Some(Value::Array(values)) => values,
None if !required => return Ok(Vec::new()),
None => return Err(bad(cmd, "`groups` must be a list of timeline folder ids")),
_ => return Err(bad(cmd, "`groups` must be a list of timeline folder ids")),
};
if values.is_empty() && required {
return Err(bad(cmd, "`groups` must list at least one folder"));
}
if values.len() > 10_000 {
return Err(bad(cmd, "too many folders"));
}
let mut ids = Vec::new();
for value in values {
let id = value.as_u64().ok_or_else(|| bad(cmd, "`groups` must contain folder ids"))?;
if !s.doc.timeline_groups.iter().any(|group| group.id == id) {
return Err(bad(cmd, format!("no timeline folder {id}")));
}
if !ids.contains(&id) {
ids.push(id);
}
}
Ok(ids)
}
fn group_move_would_cycle(s: &Session, target: u64, sources: &[u64]) -> bool {
let sources: std::collections::BTreeSet<u64> = sources.iter().copied().collect();
let parents: std::collections::BTreeMap<u64, Option<u64>> = s.doc.timeline_groups.iter().map(|group| (group.id, group.parent)).collect();
let mut current = Some(target);
let mut steps = 0usize;
while let Some(id) = current {
if sources.contains(&id) {
return true;
}
steps += 1;
if steps > s.doc.timeline_groups.len() {
return true;
}
current = parents.get(&id).copied().flatten();
}
false
}
fn group_feature_ids(doc: &solidcraft_doc::Document, roots: &[u64]) -> std::collections::BTreeSet<u64> {
let by_id: std::collections::BTreeMap<u64, &solidcraft_doc::TimelineGroup> = doc.timeline_groups.iter().map(|group| (group.id, group)).collect();
let mut children = std::collections::BTreeMap::<u64, Vec<u64>>::new();
for group in &doc.timeline_groups {
if let Some(parent) = group.parent {
children.entry(parent).or_default().push(group.id);
}
}
let mut pending = roots.to_vec();
let mut visited = std::collections::BTreeSet::new();
let mut features = std::collections::BTreeSet::new();
while let Some(id) = pending.pop() {
if !visited.insert(id) {
continue;
}
if let Some(group) = by_id.get(&id) {
features.extend(group.features.iter().copied());
pending.extend(children.get(&id).into_iter().flatten().copied());
}
}
features
}
fn sort_group_features(doc: &mut solidcraft_doc::Document) {
let positions: std::collections::BTreeMap<u64, usize> = doc.features.iter().enumerate().map(|(index, feature)| (feature.id, index)).collect();
for group in &mut doc.timeline_groups {
group.features.sort_by_key(|feature| positions.get(feature).copied().unwrap_or(usize::MAX));
}
}
fn require_contiguous_groups(doc: &solidcraft_doc::Document, cmd: &str) -> Result<()> {
let Some(id) = solidcraft_doc::non_contiguous_timeline_group(&doc.features, &doc.timeline_groups) else { return Ok(()) };
let name = doc.timeline_groups.iter().find(|group| group.id == id).map(|group| group.name.as_str()).unwrap_or("unknown");
Err(bad(cmd, format!("timeline folder `{name}` must contain one contiguous run of features")))
}
fn group(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "timeline.group";
let mut features = group_features(s, p, false, cmd)?;
let groups = group_ids(s, p, false, cmd)?;
let name = match str_(p, "name").map(str::trim).filter(|name| !name.is_empty()) {
Some(name) if name.len() <= 128 => name.to_string(),
Some(_) => return Err(bad(cmd, "`name` must be 1…128 characters")),
None => format!("Folder{}", s.doc.timeline_groups.len() + 1),
};
let id = s
.doc
.timeline_groups
.iter()
.map(|group| group.id)
.max()
.unwrap_or(0)
.checked_add(1)
.ok_or_else(|| bad(cmd, "no more timeline folder ids are available"))?;
let parent = match p.get("parent") {
Some(Value::Null) | None => None,
Some(_) => Some(timeline_group_id_field(s, p, "parent", cmd)?),
};
if let Some(parent) = parent
&& group_move_would_cycle(s, parent, &groups)
{
return Err(bad(cmd, "a new folder cannot contain an ancestor of its parent"));
}
let nested = group_feature_ids(&s.doc, &groups);
features.retain(|feature| !nested.contains(feature));
let mut next = (*s.doc).clone();
for group in &mut next.timeline_groups {
group.features.retain(|feature| !features.contains(feature));
if groups.contains(&group.id) {
group.parent = Some(id);
}
}
next.timeline_groups.push(solidcraft_doc::TimelineGroup { id, name: name.clone(), parent, features });
sort_group_features(&mut next);
require_contiguous_groups(&next, cmd)?;
*s.doc_mut() = next;
Ok(json!({"group": id, "name": name, "parent": parent, "groups": groups}))
}
fn ungroup(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "timeline.ungroup";
let ids = match p.get("groups") {
Some(Value::Array(values)) if !values.is_empty() && values.len() <= 10_000 => {
let mut ids = Vec::new();
for value in values {
let id = value.as_u64().ok_or_else(|| bad(cmd, "`groups` must contain timeline folder ids"))?;
if !s.doc.timeline_groups.iter().any(|group| group.id == id) {
return Err(bad(cmd, format!("no timeline folder {id}")));
}
if !ids.contains(&id) {
ids.push(id);
}
}
ids
}
Some(Value::Array(_)) => return Err(bad(cmd, "`groups` must list 1…10000 timeline folder ids")),
Some(_) => return Err(bad(cmd, "`groups` must be a list of timeline folder ids")),
None => vec![timeline_group_id(s, p, cmd)?],
};
let parents: std::collections::BTreeMap<u64, Option<u64>> =
s.doc.timeline_groups.iter().filter(|group| ids.contains(&group.id)).map(|group| (group.id, group.parent)).collect();
let surviving_parent = |mut parent: Option<u64>| {
let mut steps = 0usize;
while let Some(id) = parent.filter(|id| ids.contains(id)) {
parent = parents.get(&id).copied().flatten();
steps += 1;
if steps > parents.len() {
return None;
}
}
parent
};
let mut next = (*s.doc).clone();
let moved_features: Vec<(Option<u64>, Vec<u64>)> = next
.timeline_groups
.iter()
.filter(|group| ids.contains(&group.id))
.map(|group| (surviving_parent(group.parent), group.features.clone()))
.collect();
for group in &mut next.timeline_groups {
if ids.contains(&group.id) {
continue;
}
group.parent = surviving_parent(group.parent);
for (_, features) in moved_features.iter().filter(|(parent, _)| *parent == Some(group.id)) {
group.features.extend(features.iter().copied());
}
}
next.timeline_groups.retain(|group| !ids.contains(&group.id));
sort_group_features(&mut next);
require_contiguous_groups(&next, cmd)?;
*s.doc_mut() = next;
Ok(json!({"groups": ids}))
}
fn rename_group(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "timeline.rename_group";
let id = timeline_group_id(s, p, cmd)?;
let name = str_(p, "name")
.map(str::trim)
.filter(|name| !name.is_empty() && name.len() <= 128)
.ok_or_else(|| bad(cmd, "`name` must be 1…128 characters"))?;
if let Some(group) = s.doc_mut().timeline_groups.iter_mut().find(|group| group.id == id) {
group.name = name.to_string();
}
Ok(json!({"group": id, "name": name}))
}
fn move_to_group(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "timeline.move_to_group";
let features = group_features(s, p, false, cmd)?;
let groups = group_ids(s, p, false, cmd)?;
if features.is_empty() && groups.is_empty() {
return Err(bad(cmd, "list at least one feature or folder"));
}
let target = match p.get("group") {
Some(Value::Null) | None => None,
Some(_) => Some(timeline_group_id(s, p, cmd)?),
};
if let Some(target) = target
&& group_move_would_cycle(s, target, &groups)
{
return Err(bad(cmd, "a folder cannot be moved into itself or one of its descendants"));
}
let mut next = (*s.doc).clone();
for group in &mut next.timeline_groups {
group.features.retain(|feature| !features.contains(feature));
if Some(group.id) == target {
group.features.extend(features.iter().copied());
}
}
for group in &mut next.timeline_groups {
if groups.contains(&group.id) {
group.parent = target;
}
}
sort_group_features(&mut next);
require_contiguous_groups(&next, cmd)?;
*s.doc_mut() = next;
Ok(json!({"features": features, "groups": groups, "group": target}))
}
fn move_many(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "timeline.move_many";
let mut features = group_features(s, p, false, cmd)?;
let groups = group_ids(s, p, false, cmd)?;
if features.is_empty() && groups.is_empty() {
return Err(bad(cmd, "list at least one feature or folder"));
}
let target = match p.get("group") {
Some(Value::Null) | None => None,
Some(_) => Some(timeline_group_id(s, p, cmd)?),
};
if let Some(target) = target
&& group_move_would_cycle(s, target, &groups)
{
return Err(bad(cmd, "a folder cannot be moved into itself or one of its descendants"));
}
let nested = group_feature_ids(&s.doc, &groups);
features.retain(|feature| !nested.contains(feature));
let moving: Vec<u64> =
s.doc.features.iter().filter(|feature| nested.contains(&feature.id) || features.contains(&feature.id)).map(|feature| feature.id).collect();
let mut next = (*s.doc).clone();
for group in &mut next.timeline_groups {
group.features.retain(|feature| !features.contains(feature));
if Some(group.id) == target {
group.features.extend(features.iter().copied());
}
if groups.contains(&group.id) {
group.parent = target;
}
}
sort_group_features(&mut next);
let slot = num(p, "position").map(|x| x.max(0.0) as usize).ok_or_else(|| bad(cmd, "`position` must be a number"))?;
let (next, ordered) = reorder_features(s, next, &moving, slot, cmd)?;
if next != *s.doc {
*s.doc_mut() = next;
}
Ok(json!({"features": ordered, "groups": groups, "group": target, "position": slot}))
}
fn rollback(s: &mut Session, p: &Value) -> Result<Value> {
let n = s.doc.features.len();
let pos = match p.get("feature") {
@@ -423,6 +736,8 @@ fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
let f = next.features.remove(from);
let name = f.name.clone();
next.features.insert(to, f);
sort_group_features(&mut next);
require_contiguous_groups(&next, cmd)?;
// Everything that evaluated before must still evaluate.
let mut after = solidcraft_doc::Model::new();
after.evaluate(&next);
@@ -435,6 +750,60 @@ fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"feature": id, "position": to}))
}
fn reorder_many(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "timeline.reorder_many";
let ids = group_features(s, p, true, cmd)?;
let slot = num(p, "position").map(|x| x.max(0.0) as usize).ok_or_else(|| bad(cmd, "`position` must be a number"))?;
let (next, ordered) = reorder_features(s, (*s.doc).clone(), &ids, slot, cmd)?;
if next != *s.doc {
*s.doc_mut() = next;
}
Ok(json!({"features": ordered, "position": slot.min(s.doc.features.len())}))
}
fn reorder_features(
s: &Session,
mut next: solidcraft_doc::Document,
ids: &[u64],
slot: usize,
cmd: &str,
) -> Result<(solidcraft_doc::Document, Vec<u64>)> {
let n = next.features.len();
let slot = slot.min(n);
let selected: std::collections::BTreeSet<u64> = ids.iter().copied().collect();
let ordered: Vec<u64> = next.features.iter().filter(|feature| selected.contains(&feature.id)).map(|feature| feature.id).collect();
let before_slot = next.features.iter().take(slot).filter(|feature| selected.contains(&feature.id)).count();
let insert_at = slot.saturating_sub(before_slot).min(n.saturating_sub(ordered.len()));
let mut moving = Vec::new();
next.features.retain(|feature| {
if selected.contains(&feature.id) {
moving.push(feature.clone());
false
} else {
true
}
});
let unchanged = s.doc.features.iter().map(|feature| feature.id).eq(next.features[..insert_at]
.iter()
.chain(moving.iter())
.chain(next.features[insert_at..].iter())
.map(|feature| feature.id));
next.features.splice(insert_at..insert_at, moving);
sort_group_features(&mut next);
require_contiguous_groups(&next, cmd)?;
if !unchanged {
let mut after = solidcraft_doc::Model::new();
after.evaluate(&next);
let broken = newly_broken(&s.model, &after);
if !broken.is_empty() {
let list: Vec<String> = broken.iter().map(|(name, error)| format!("{name} ({error})")).collect();
return Err(bad(cmd, format!("moving the selected features there breaks {}", list.join("; "))));
}
}
Ok((next, ordered))
}
fn input_keys(kind: &FeatureKind) -> Vec<(String, solidcraft_doc::expr::Kind)> {
let mut keys: Vec<_> = kind.inputs().into_iter().map(|(label, _, unit)| (label.to_string(), unit)).collect();
if let FeatureKind::Fillet { style, .. } = kind {
+160
View File
@@ -310,3 +310,163 @@ fn inserting_variable_fillet_control_preserves_parameters_at_existing_positions(
assert_eq!(length["value"], 3.0);
assert_eq!(width["value"], 4.0);
}
#[test]
fn timeline_folders_are_command_backed_saved_undoable_and_follow_feature_ids() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "name": "Base"}));
run(&mut s, "solid.fillet", json!({"edges": [[10, 5, 10]], "radius": 1, "name": "Round"}));
let base = s.doc.find_feature("Base").unwrap().id;
let round = s.doc.find_feature("Round").unwrap().id;
let made = run(&mut s, "timeline.group", json!({"name": "Housing", "features": ["Base", round]}));
let group = made["group"].as_u64().unwrap();
assert_eq!(s.doc.timeline_groups[0].features, vec![base, round]);
run(&mut s, "timeline.rename", json!({"feature": base, "name": "Foundation"}));
assert_eq!(s.doc.timeline_groups[0].features, vec![base, round], "folders follow stable ids, not names");
run(&mut s, "timeline.rename_group", json!({"group": group, "name": "Main housing"}));
run(&mut s, "timeline.move_to_group", json!({"features": [round], "group": null}));
assert_eq!(s.doc.timeline_groups[0].features, vec![base]);
let reopened = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
assert_eq!(reopened.timeline_groups, s.doc.timeline_groups);
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.timeline_groups[0].features, vec![base, round]);
run(&mut s, "timeline.delete", json!({"features": [base]}));
assert_eq!(s.doc.timeline_groups[0].features, vec![round], "deleted features leave folders cleanly");
run(&mut s, "timeline.ungroup", json!({"group": group}));
assert!(s.doc.timeline_groups.is_empty());
}
#[test]
fn timeline_reorders_selected_features_as_one_block_and_removes_folders_in_one_step() {
let mut s = Session::default();
for (name, body, x) in [("First", "FirstBody", 0), ("Second", "SecondBody", 20), ("Third", "ThirdBody", 40)] {
run(
&mut s,
"solid.box",
json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0], "operation": "new_body", "name": name, "body_name": body}),
);
}
let names = |session: &Session| session.doc.features.iter().map(|feature| feature.name.clone()).collect::<Vec<_>>();
assert_eq!(names(&s), vec!["First", "Second", "Third"]);
run(&mut s, "timeline.reorder_many", json!({"features": ["First", "Third"], "position": 3}));
assert_eq!(names(&s), vec!["Second", "First", "Third"], "selected features keep their relative order");
run(&mut s, "edit.undo", json!({}));
assert_eq!(names(&s), vec!["First", "Second", "Third"]);
run(&mut s, "edit.redo", json!({}));
assert_eq!(names(&s), vec!["Second", "First", "Third"]);
run(&mut s, "edit.undo", json!({}));
let first = run(&mut s, "timeline.group", json!({"name": "One", "features": ["First"]}))["group"].as_u64().unwrap();
run(&mut s, "edit.undo", json!({}));
assert!(s.doc.timeline_groups.is_empty(), "creating a folder is undoable");
run(&mut s, "edit.redo", json!({}));
assert_eq!(s.doc.timeline_groups.first().map(|group| group.id), Some(first));
let second = run(&mut s, "timeline.group", json!({"name": "Two", "features": ["Second"]}))["group"].as_u64().unwrap();
run(&mut s, "timeline.ungroup", json!({"groups": [first, second]}));
assert!(s.doc.timeline_groups.is_empty());
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.timeline_groups.len(), 2, "removing several folders is one undoable command");
run(&mut s, "edit.redo", json!({}));
assert!(s.doc.timeline_groups.is_empty(), "removing folders is redoable");
}
#[test]
fn timeline_folders_refuse_unknown_non_contiguous_and_split_contents() {
let mut s = Session::default();
for (name, body, x) in [("First", "FirstBody", 0), ("Second", "SecondBody", 20), ("Third", "ThirdBody", 40)] {
run(
&mut s,
"solid.box",
json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0], "operation": "new_body", "name": name, "body_name": body}),
);
}
let before = s.doc.clone();
let error = s.execute("timeline.group", &json!({"features": ["First", "Third"]})).unwrap_err().to_string();
assert!(error.contains("contiguous"), "{error}");
assert_eq!(s.doc, before);
let group = run(&mut s, "timeline.group", json!({"name": "Pair", "features": ["First", "Second"]}))["group"].as_u64().unwrap();
let before = s.doc.clone();
let error = s.execute("timeline.reorder_many", &json!({"features": ["First"], "position": 3})).unwrap_err().to_string();
assert!(error.contains("contiguous"), "{error}");
assert_eq!(s.doc, before, "a refused reorder must not split the folder");
for params in [json!({"features": [999_u64], "group": group}), json!({"groups": [999_u64], "group": group})] {
let error = s.execute("timeline.move_to_group", &params).unwrap_err().to_string();
assert!(error.contains("no feature") || error.contains("no timeline folder"), "{error}");
}
let mut damaged: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
let first = s.doc.find_feature("First").unwrap().id;
let third = s.doc.find_feature("Third").unwrap().id;
damaged["timeline_groups"] = json!([{"id": 1, "name": "Broken", "features": [first, third]}]);
let error = solidcraft_doc::Document::from_json(&damaged.to_string()).unwrap_err().to_string();
assert!(error.contains("not contiguous"), "{error}");
}
#[test]
fn timeline_move_many_is_atomic_and_one_undo_step() {
let mut s = Session::default();
for (name, body, x) in [("First", "FirstBody", 0), ("Second", "SecondBody", 20), ("Third", "ThirdBody", 40)] {
run(
&mut s,
"solid.box",
json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0], "operation": "new_body", "name": name, "body_name": body}),
);
}
let group = run(&mut s, "timeline.group", json!({"name": "Pair", "features": ["First"]}))["group"].as_u64().unwrap();
let before = (*s.doc).clone();
run(&mut s, "timeline.move_many", json!({"features": ["Third"], "group": group, "position": 1}));
assert_eq!(s.doc.features.iter().map(|feature| feature.name.as_str()).collect::<Vec<_>>(), vec!["First", "Third", "Second"]);
assert_eq!(s.doc.timeline_groups[0].features.len(), 2);
run(&mut s, "edit.undo", json!({}));
assert_eq!(*s.doc, before, "one undo restores both order and folder membership");
run(&mut s, "edit.redo", json!({}));
assert_eq!(s.doc.timeline_groups[0].features.len(), 2);
let child = run(&mut s, "timeline.group", json!({"name": "Child", "features": ["Second"]}))["group"].as_u64().unwrap();
let before_parent = (*s.doc).clone();
let parent = run(&mut s, "timeline.group", json!({"name": "Parent", "groups": [group, child]}))["group"].as_u64().unwrap();
assert!(s.doc.timeline_groups.iter().filter(|folder| folder.parent == Some(parent)).count() == 2);
run(&mut s, "edit.undo", json!({}));
assert_eq!(*s.doc, before_parent, "creating a parent for selected folders is one undo step");
}
#[test]
fn failed_timeline_move_many_keeps_folder_membership_and_order() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "name": "Base"}));
run(&mut s, "solid.fillet", json!({"edges": [[10, 5, 10]], "radius": 1, "name": "Round"}));
let group = run(&mut s, "timeline.group", json!({"name": "Destination"}))["group"].as_u64().unwrap();
let before = s.doc.clone();
let undo = s.undo.len();
let error = s.execute("timeline.move_many", &json!({"features": ["Base"], "group": group, "position": 2})).unwrap_err().to_string();
assert!(error.contains("breaks"), "{error}");
assert_eq!(s.doc, before);
assert_eq!(s.undo.len(), undo, "a refused atomic move adds no undo step");
}
#[test]
fn timeline_folders_can_nest_and_reject_cycles() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "name": "Base"}));
let root = run(&mut s, "timeline.group", json!({"name": "Root"}))["group"].as_u64().unwrap();
let child = run(&mut s, "timeline.group", json!({"name": "Child", "parent": root}))["group"].as_u64().unwrap();
assert_eq!(s.doc.timeline_groups.iter().find(|group| group.id == child).unwrap().parent, Some(root));
run(&mut s, "timeline.move_to_group", json!({"groups": [child], "group": null}));
assert_eq!(s.doc.timeline_groups.iter().find(|group| group.id == child).unwrap().parent, None);
run(&mut s, "timeline.move_to_group", json!({"groups": [child], "group": root}));
let error = s.execute("timeline.move_to_group", &json!({"groups": [root], "group": child})).unwrap_err().to_string();
assert!(error.contains("itself") || error.contains("descendant"), "{error}");
let before = s.doc.clone();
let error = s.execute("timeline.move_many", &json!({"groups": [root], "group": child, "position": 0})).unwrap_err().to_string();
assert!(error.contains("itself") || error.contains("descendant"), "{error}");
assert_eq!(s.doc, before);
let reopened = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
assert_eq!(reopened.timeline_groups, s.doc.timeline_groups);
run(&mut s, "timeline.ungroup", json!({"group": root}));
assert_eq!(s.doc.timeline_groups.iter().find(|group| group.id == child).unwrap().parent, None, "deleting a folder promotes its children");
}
+71 -8
View File
@@ -55,6 +55,10 @@ pub struct TreeState {
pub picked_components: Vec<u64>,
/// Canvases picked in the tree.
pub picked_canvases: Vec<u64>,
/// Folders picked in the feature history (features use the engine selection).
pub picked_timeline_groups: Vec<u64>,
/// Last feature-history item clicked, for Shift ranges (`f:<id>` or `g:<id>`).
pub timeline_anchor: Option<String>,
/// The Edit Canvas / Calibrate panel.
pub canvas_panel: Option<CanvasPanel>,
/// What waits for the Capture Position question: a command id, or [`SAVE`].
@@ -496,6 +500,15 @@ struct Actions {
}
pub fn browser(app: &mut SolveApp, ui: &mut egui::Ui) {
browser_panel(app, ui, false);
}
/// The left-sidebar layout: feature history first, parts and scene browser below it.
pub fn browser_below_timeline(app: &mut SolveApp, ui: &mut egui::Ui) {
browser_panel(app, ui, true);
}
fn browser_panel(app: &mut SolveApp, ui: &mut egui::Ui, timeline_above: bool) {
let t = Tokens::get();
app.tree.order.clear();
DRAWN.with(|d| d.borrow_mut().clear());
@@ -506,16 +519,66 @@ pub fn browser(app: &mut SolveApp, ui: &mut egui::Ui) {
.resizable(false)
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(6, 6)))
.show(ui, |ui| {
ui.label(RichText::new("BROWSER").size(11.0).strong().color(t.text_dim));
ui.add_space(4.0);
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.scroll_source(egui::scroll_area::ScrollSource { scroll_bar: true, mouse_wheel: true, ..egui::scroll_area::ScrollSource::NONE })
.show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 0.0;
component_rows(app, ui, 0, 0, &mut acts);
if timeline_above {
let available = ui.available_height();
let browser_collapsed = app.preferences.timeline_left_browser_collapsed;
let max_timeline_height = (available - if browser_collapsed { 30.0 } else { 130.0 }).max(150.0);
let remembered = app.preferences.timeline_left_height;
let timeline_height =
if remembered.is_finite() { remembered.clamp(150.0, max_timeline_height) } else { 360.0_f32.clamp(150.0, max_timeline_height) };
ui.allocate_ui_with_layout(vec2(ui.available_width(), timeline_height), egui::Layout::top_down(egui::Align::Min), |ui| {
crate::timeline_left::left_timeline(app, ui)
});
if !browser_collapsed {
let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), 8.0), egui::Sense::hover());
let response = ui
.interact(rect, egui::Id::new("left_history_divider"), egui::Sense::drag())
.on_hover_cursor(egui::CursorIcon::ResizeVertical);
if response.dragged() {
let delta = ui.input(|input| input.pointer.delta().y);
if delta.is_finite() {
app.preferences.timeline_left_height = (timeline_height + delta).clamp(150.0, max_timeline_height);
}
}
ui.painter().hline(rect.x_range(), rect.center().y, egui::Stroke::new(1.0, t.border));
crate::scenario::publish_handle("left-history-divider", rect.center());
}
let arrow = if browser_collapsed { "▶" } else { "▼" };
let response =
ui.add(egui::Button::new(RichText::new(format!("{arrow} BROWSER")).size(11.0).strong().color(t.text_dim)).frame(false));
crate::scenario::publish_handle("left-browser-toggle", response.rect.center());
if response.clicked() {
app.preferences.timeline_left_browser_collapsed = !browser_collapsed;
}
if !browser_collapsed {
ui.add_space(4.0);
browser_rows(app, ui, &mut acts);
}
} else {
browser_contents(app, ui, &mut acts);
}
});
finish_browser(app, ui, acts);
}
fn browser_contents(app: &mut SolveApp, ui: &mut egui::Ui, acts: &mut Actions) {
let t = Tokens::get();
ui.label(RichText::new("BROWSER").size(11.0).strong().color(t.text_dim));
ui.add_space(4.0);
browser_rows(app, ui, acts);
}
fn browser_rows(app: &mut SolveApp, ui: &mut egui::Ui, acts: &mut Actions) {
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.scroll_source(egui::scroll_area::ScrollSource { scroll_bar: true, mouse_wheel: true, ..egui::scroll_area::ScrollSource::NONE })
.show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 0.0;
component_rows(app, ui, 0, 0, acts);
});
}
fn finish_browser(app: &mut SolveApp, ui: &mut egui::Ui, acts: Actions) {
app.viewport.hover_bodies = acts.hover_bodies;
if let Some(d) = acts.drop {
apply_drop(app, d);
+10 -2
View File
@@ -174,6 +174,7 @@ pub enum RenameWhat {
Feature(u64),
Component(u64),
Group(u64),
TimelineGroup(u64),
Canvas(u64),
View(String),
Joint(u64),
@@ -184,7 +185,7 @@ pub struct Rename {
pub what: RenameWhat,
pub text: String,
pub at: Pos2,
focused: bool,
pub(crate) focused: bool,
}
/// Radial slots, clockwise from the top.
@@ -1136,6 +1137,7 @@ pub fn start_rename(app: &mut SolveApp, what: RenameWhat, at: Pos2) {
RenameWhat::Component(0) => doc.name.clone(),
RenameWhat::Component(c) => doc.components.iter().find(|x| x.id == *c).map(|x| x.name.clone()).unwrap_or_default(),
RenameWhat::Group(g) => crate::browser::group_name(app, *g).unwrap_or_default(),
RenameWhat::TimelineGroup(g) => doc.timeline_groups.iter().find(|group| group.id == *g).map(|group| group.name.clone()).unwrap_or_default(),
RenameWhat::Canvas(c) => doc.canvases.iter().find(|x| x.id == *c).map(|x| x.name.clone()).unwrap_or_default(),
RenameWhat::View(v) => v.clone(),
RenameWhat::Joint(j) => doc.assembly.joints.iter().find(|x| x.id == *j).map(|x| x.name.clone()).unwrap_or_default(),
@@ -1143,7 +1145,7 @@ pub fn start_rename(app: &mut SolveApp, what: RenameWhat, at: Pos2) {
app.menu.rename = Some(Rename { what, text, at, focused: false });
}
fn commit_rename(app: &mut SolveApp, r: &Rename) {
pub(crate) fn commit_rename(app: &mut SolveApp, r: &Rename) {
let name = r.text.trim().to_string();
if name.is_empty() {
return;
@@ -1161,6 +1163,7 @@ fn commit_rename(app: &mut SolveApp, r: &Rename) {
RenameWhat::Feature(f) => drop(app.run("timeline.rename", json!({ "feature": f, "name": name }))),
RenameWhat::Component(c) => drop(app.run("component.rename", json!({ "component": c, "name": name }))),
RenameWhat::Group(g) => crate::browser::group_action(app, "ui.renameGroup", &json!({ "group": g, "name": name })),
RenameWhat::TimelineGroup(g) => drop(app.run("timeline.rename_group", json!({ "group": g, "name": name }))),
RenameWhat::Canvas(c) => drop(app.run("canvas.edit", json!({ "canvas": c, "name": name }))),
RenameWhat::View(v) => drop(app.run("view.rename", json!({ "view": v, "name": name }))),
RenameWhat::Joint(j) => drop(app.run("joint.edit", json!({ "joint": j, "name": name }))),
@@ -1566,6 +1569,11 @@ pub fn list_row_lit(ui: &mut egui::Ui, item: &Item, lit: bool) -> Option<Item> {
}
fn show_rename(app: &mut SolveApp, ctx: &egui::Context) {
// Feature-history folders own their editor inside the row. Keep the shared rename state so
// automation and keyboard handling still use the same path, but do not draw the floating box.
if app.preferences.timeline_left && matches!(app.menu.rename.as_ref().map(|rename| &rename.what), Some(RenameWhat::TimelineGroup(_))) {
return;
}
let Some(mut r) = app.menu.rename.take() else { return };
let t = Tokens::get();
let mut done: Option<bool> = None;
+7 -3
View File
@@ -227,7 +227,7 @@ pub enum Kind {
},
/// Delete a feature that others depend on.
ConfirmDelete {
feature: u64,
features: Vec<u64>,
with: Vec<String>,
fail: Vec<String>,
},
@@ -617,7 +617,11 @@ impl Dialog {
}
pub fn confirm_delete(feature: u64, with: Vec<String>, fail: Vec<String>) -> Dialog {
Dialog::new(Kind::ConfirmDelete { feature, with, fail }, vec![])
Dialog::confirm_delete_many(vec![feature], with, fail)
}
pub fn confirm_delete_many(features: Vec<u64>, with: Vec<String>, fail: Vec<String>) -> Dialog {
Dialog::new(Kind::ConfirmDelete { features, with, fail }, vec![])
}
pub fn edit_param(s: &Session, name: &str) -> Dialog {
@@ -3212,7 +3216,7 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
}
Kind::EditParam { name, expr } => ("parameters.change", json!({"name": name, "expression": expr})),
Kind::Rename { feature, name } => ("timeline.rename", json!({"feature": feature, "name": name})),
Kind::ConfirmDelete { feature, .. } => ("timeline.delete", json!({ "features": [feature.to_string()] })),
Kind::ConfirmDelete { features, .. } => ("timeline.delete", json!({ "features": features })),
Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. } => return Ok(Vec::new()),
Kind::Assembly(k) => return crate::dialogs_assembly::commands(app, k, &d.inputs),
Kind::Sheet(k) => return crate::dialogs_sheet::commands(app, k, &d.inputs, &d.extra),
+44
View File
@@ -1049,6 +1049,50 @@ pub fn timeline_joints(app: &mut SolveApp, ui: &mut egui::Ui, p: &egui::Painter,
x
}
/// Named joint rows for the vertical feature-history layout.
pub fn timeline_joints_vertical(app: &mut SolveApp, ui: &mut egui::Ui) {
let t = Tokens::get();
let joints: Vec<(u64, String, JointKind, bool)> =
app.session.doc.assembly.joints.iter().map(|joint| (joint.id, joint.name.clone(), joint.kind, joint.suppressed)).collect();
if joints.is_empty() {
return;
}
ui.add_space(4.0);
ui.label(egui::RichText::new("JOINTS").size(10.0).strong().color(t.text_dim));
let mut open = None;
let mut run = None;
for (id, name, kind, suppressed) in joints {
let (rect, response) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), egui::Sense::click());
if response.hovered() {
ui.painter().rect_filled(rect, 3.0, t.hover);
}
let icon = egui::Rect::from_min_size(egui::pos2(rect.left() + 4.0, rect.top() + 4.0), vec2(22.0, 22.0));
crate::icons::paint(ui.painter(), icon, "joint", if suppressed { t.border } else { t.icon }, t.icon_fill, t.accent);
if suppressed {
ui.painter().line_segment([icon.left_bottom(), icon.right_top()], Stroke::new(1.5, t.text_dim));
}
ui.painter().text(
egui::pos2(icon.right() + 7.0, rect.center().y),
egui::Align2::LEFT_CENTER,
&name,
egui::FontId::proportional(12.0),
if suppressed { t.text_dim } else { t.text },
);
let response = response.on_hover_text(format!("{name} ({kind:?} joint)\nDouble-click to edit · right-click for more"));
if response.double_clicked() {
open = edit_joint(app, id);
}
response.context_menu(|ui| joint_menu(app, ui, (id, kind, suppressed), &mut open, &mut run));
}
if let Some((command, params)) = run {
let _ = app.run(command, params);
}
if let Some(dialog) = open {
app.tool = None;
app.dialog = Some(dialog);
}
}
/// The Joints folder at the end of the Browser: a row per joint (double-click edits, the menu
/// edits, drives, suppresses or deletes).
pub fn browser_joints(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize) {
+11 -3
View File
@@ -4,7 +4,7 @@
//! commands ([`SolveApp::run`]). Nothing here owns design data. The layout follows the familiar
//! parametric-CAD desktop: application bar, a toolbar of workspace tabs and panels, the browser
//! on the left, the 3D viewport with an axis view and navigation bar, the command dialog on the
//! right of the viewport, the command palette and the timeline at the bottom.
//! right of the viewport, the command palette and a bottom or left-sidebar feature timeline.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
@@ -117,6 +117,7 @@ pub mod sketch_tools;
pub mod stand_up;
pub mod theme;
pub mod timeline;
pub mod timeline_left;
pub mod titlebar;
pub mod toolbar;
pub mod toolbar_custom;
@@ -1077,12 +1078,19 @@ impl SolveApp {
}
}
toolbar::toolbar(self, ui);
if self.ui.show_timeline {
let left_timeline = self.ui.show_timeline && self.preferences.timeline_left;
if self.ui.show_timeline && !left_timeline {
timeline::timeline(self, ui);
}
palette::palette_bar(self, ui);
if self.ui.show_browser {
browser::browser(self, ui);
if left_timeline {
browser::browser_below_timeline(self, ui);
} else {
browser::browser(self, ui);
}
} else if left_timeline {
timeline_left::left_panel(self, ui);
}
preview::update(self, ui.ctx());
egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| {
+28 -2
View File
@@ -1,6 +1,6 @@
//! Preferences, one dialog in the spirit of Fusion's, opened from the gear at the top right of the
//! window, File › Preferences or Ctrl+, (Cmd+, on macOS): General (theme, UI scale, units of new
//! designs, autosave, display precision, the agent cursor), Navigation (mouse presets, zoom
//! designs, autosave, display precision, timeline layout, the agent cursor), Navigation (mouse presets, zoom
//! direction, zoom to the cursor, orbit centre, Look At animation), Keyboard Shortcuts (search,
//! rebind, conflicts; `keymap`), Design (default modelling orientation, Y up or Z up), Graphics
//! (visual style, grid, shadow, perspective, anti-aliasing), Sketch and Selection. Changes apply
@@ -223,6 +223,13 @@ pub struct Prefs {
pub language: String,
/// Interface scale (1 = 100 %).
pub ui_scale: f32,
/// Put the feature history above the parts browser in the left sidebar instead of along the
/// bottom of the viewport.
pub timeline_left: bool,
/// Height of the feature-history section when it shares the left sidebar with the browser.
pub timeline_left_height: f32,
/// Whether the browser below the left feature history is collapsed.
pub timeline_left_browser_collapsed: bool,
/// The wheel zooms toward the point under the cursor (else toward the view's centre).
pub zoom_to_cursor: bool,
/// Spread discrete wheel input across frames (off: apply each event immediately).
@@ -285,6 +292,9 @@ impl Default for Prefs {
theme: "dark".into(),
language: "en".into(),
ui_scale: 1.0,
timeline_left: false,
timeline_left_height: 360.0,
timeline_left_browser_collapsed: false,
zoom_to_cursor: true,
smooth_wheel_zoom: true,
trackpad_pan: true,
@@ -376,6 +386,9 @@ fn defaults(app: &mut SolveApp, page: usize) {
p.theme = d.theme;
p.language = d.language;
p.ui_scale = d.ui_scale;
p.timeline_left = d.timeline_left;
p.timeline_left_height = d.timeline_left_height;
p.timeline_left_browser_collapsed = d.timeline_left_browser_collapsed;
p.default_units = d.default_units;
p.length_decimals = d.length_decimals;
p.angle_decimals = d.angle_decimals;
@@ -847,6 +860,13 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
);
crate::scenario::publish_rect("prefs:scale", scale.rect);
ui.end_row();
ui.label("Feature history");
ui.horizontal(|ui| {
ui.radio_value(&mut app.preferences.timeline_left, false, "Bottom timeline");
ui.radio_value(&mut app.preferences.timeline_left, true, "Left sidebar");
});
ui.end_row();
note(ui, "The left layout shows the rollback bar above the parts and scene browser.");
ui.label(app.tr("ui.units_of_new_designs"));
egui::ComboBox::from_id_salt("pref_units").selected_text(app.preferences.default_units.clone()).show_ui(ui, |ui| {
for u in UNITS {
@@ -1405,8 +1425,11 @@ mod tests {
assert_eq!(app.tr("ui.extrude"), "Extrude");
// Switch language to Japanese
set(&mut app, &json!({"language": "ja"})).unwrap();
set(&mut app, &json!({"language": "ja", "timeline_left": true})).unwrap();
app.preferences.timeline_left_height = 275.0;
app.preferences.timeline_left_browser_collapsed = true;
assert_eq!(app.preferences.language, "ja");
assert!(app.preferences.timeline_left);
assert_eq!(app.tr("ui.preferences"), "環境設定");
assert_eq!(app.tr("ui.extrude"), "押し出し");
@@ -1414,6 +1437,9 @@ mod tests {
let mut other = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
other.load_prefs(&app.prefs());
assert_eq!(other.preferences.language, "ja");
assert!(other.preferences.timeline_left);
assert_eq!(other.preferences.timeline_left_height, 275.0);
assert!(other.preferences.timeline_left_browser_collapsed);
assert_eq!(other.tr("ui.preferences"), "環境設定");
}
+22 -15
View File
@@ -7,7 +7,7 @@ use solidcraft_engine::doc::FeatureKind;
use crate::theme::Tokens;
use crate::{SolveApp, icons};
fn icon_of(k: &FeatureKind) -> &'static str {
pub(crate) fn icon_of(k: &FeatureKind) -> &'static str {
match k {
FeatureKind::Sketch { .. } => "sketch",
FeatureKind::Extrude { .. } => "extrude",
@@ -37,8 +37,7 @@ fn icon_of(k: &FeatureKind) -> &'static str {
}
}
/// What a drag in the timeline is moving.
#[derive(Clone, Copy, Debug, PartialEq)]
#[derive(Clone, Debug, PartialEq)]
enum Drag {
Marker,
Feature(u64),
@@ -57,16 +56,24 @@ fn slot_at(xs: &[f32], end: f32, x: f32) -> usize {
}
/// Ask before deleting a feature others depend on; delete right away otherwise.
fn delete_feature(app: &mut SolveApp, id: u64) {
let deps = app.session.execute("timeline.dependents", &json!({ "feature": id })).unwrap_or_default();
let list = |k: &str| -> Vec<String> {
deps.get(k).and_then(|v| v.as_array()).into_iter().flatten().filter_map(|x| x.as_str().map(String::from)).collect()
};
let (with, fail) = (list("deleted_with_it"), list("would_fail"));
pub(crate) fn delete_feature(app: &mut SolveApp, id: u64) {
delete_features(app, vec![id]);
}
pub(crate) fn delete_features(app: &mut SolveApp, ids: Vec<u64>) {
let mut with = std::collections::BTreeSet::new();
let mut fail = std::collections::BTreeSet::new();
for id in &ids {
let deps = app.session.execute("timeline.dependents", &json!({ "feature": id })).unwrap_or_default();
for (key, output) in [("deleted_with_it", &mut with), ("would_fail", &mut fail)] {
output
.extend(deps.get(key).and_then(|value| value.as_array()).into_iter().flatten().filter_map(|value| value.as_str().map(String::from)));
}
}
if with.is_empty() && fail.is_empty() {
let _ = app.run("timeline.delete", json!({ "features": [id.to_string()] }));
let _ = app.run("timeline.delete", json!({ "features": ids }));
} else {
app.dialog = Some(crate::dialogs::Dialog::confirm_delete(id, with, fail));
app.dialog = Some(crate::dialogs::Dialog::confirm_delete_many(ids, with.into_iter().collect(), fail.into_iter().collect()));
}
}
@@ -281,12 +288,12 @@ pub fn timeline(app: &mut SolveApp, ui: &mut egui::Ui) {
// The scroll range follows the drawn content (the buttons don't take layout space themselves).
ui.allocate_exact_size(vec2((content_end - origin).max(0.0), r.height()), Sense::hover());
// Drag feedback, and the drop.
if let (Some(dg), Some(pp)) = (drag, pointer) {
if let (Some(dg), Some(pp)) = (drag.as_ref(), pointer) {
let slot = slot_at(&xs, end, pp.x);
let sx = xs.get(slot).copied().unwrap_or(end) - 4.0;
p.line_segment(
[pos2(sx, r.top() + 3.0), pos2(sx, r.bottom() - 3.0)],
Stroke::new(2.0, if dg == Drag::Marker { t.accent } else { t.sketch_accent }),
Stroke::new(2.0, if matches!(dg, Drag::Marker) { t.accent } else { t.sketch_accent }),
);
}
let released = ui.input(|i| i.pointer.any_released());
@@ -312,7 +319,7 @@ pub fn timeline(app: &mut SolveApp, ui: &mut egui::Ui) {
}
ui.data_mut(|d| match drag {
Some(dg) => {
d.insert_temp(drag_id, dg);
d.insert_temp(drag_id, dg.clone());
}
None => d.remove::<Drag>(drag_id),
});
@@ -334,7 +341,7 @@ fn find_in_browser(app: &mut SolveApp, id: u64) {
}
/// Transport buttons drawn as shapes (the UI font has no media glyphs).
fn transport_glyph(p: &egui::Painter, c: egui::Pos2, what: &str, col: Color32) {
pub(crate) fn transport_glyph(p: &egui::Painter, c: egui::Pos2, what: &str, col: Color32) {
let tri = |dir: f32, dx: f32| {
let pts = vec![pos2(c.x + dx - 4.0 * dir, c.y - 5.0), pos2(c.x + dx + 4.0 * dir, c.y), pos2(c.x + dx - 4.0 * dir, c.y + 5.0)];
p.add(egui::Shape::convex_polygon(pts, col, Stroke::NONE));
File diff suppressed because it is too large. Load diff
+5 -1
View File
@@ -1235,7 +1235,11 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
crate::context_menu::rename_selection(app, at);
}
Key::F6 => app.animate_view("fit"),
Key::Delete | Key::Backspace => crate::viewport::delete_selection(app),
Key::Delete | Key::Backspace => {
if !crate::timeline_left::delete_selected_folders(app) {
crate::viewport::delete_selection(app);
}
}
_ => crate::keymap::press(app, &crate::keymap::key_name(k, mods)),
}
}
+8 -2
View File
@@ -1,6 +1,6 @@
# Features
SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **328 commands.** Every command runs the same way from the toolbar, a script, `solidcraft-cli exec <id> '<json>'`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md).
SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **335 commands.** Every command runs the same way from the toolbar, a script, `solidcraft-cli exec <id> '<json>'`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md).
## Sketch (98)
@@ -257,16 +257,22 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm
| Configuration Rule | `config.rule` | – |
| Configuration Rules | `config.rules` | – |
## Timeline (13)
## Timeline (19)
| Command | Id | Toolbar |
|---|---|---|
| Compute All | `timeline.compute_all` | SOLID › MODIFY |
| Delete (Delete) | `timeline.delete` | SOLID › MODIFY |
| Rename | `timeline.rename` | – |
| New Feature Folder | `timeline.group` | – |
| Remove Feature Folders | `timeline.ungroup` | – |
| Rename Feature Folder | `timeline.rename_group` | – |
| Move Features to Folder | `timeline.move_to_group` | – |
| Move Timeline Items | `timeline.move_many` | – |
| Move Timeline Marker | `timeline.rollback` | – |
| Roll History Marker | `timeline.roll_to` | – |
| Reorder Feature | `timeline.reorder` | – |
| Reorder Features | `timeline.reorder_many` | – |
| Edit Feature | `timeline.redefine` | – |
| Feature Dependents | `timeline.dependents` | – |
| Suppress Feature | `timeline.suppress` | – |