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:
1 parent
be55a49b04
commit
a8b3b21fd0
17 files changed
+2336
-37
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user