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
+1
-1
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", ¶ms).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");
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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"), "環境設定");
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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` | – |
|
||||
|
||||
Reference in new issue
Block a user