Create construction planes along drawn 3D sketch paths (#240)

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-12 11:44:19 -04:00
1 parent 6bf86f1f1e
commit 4d4b3a715f
8 files changed
+210 -3

No files matched your search

+6
View File
@@ -321,6 +321,12 @@ the active plane. Bridge endpoints capture their source positions; associative e
construction-plane selection of 3D wires and loft guide rails remain separate work. construction-plane selection of 3D wires and loft guide rails remain separate work.
Feature dialog header chevrons collapse and restore the form without discarding selections Feature dialog header chevrons collapse and restore the form without discarding selections
or edited values, including the Appearance panel. or edited values, including the Appearance panel.
## Construction on 3D paths
Plane Along Path has a viewport path selector, fractional position, live preview and timeline
editing. Construction planes and points resolve drawn 3D wires without applying their sketch
plane a second time. Curved paths use sampled segments; multi-curve chains, absolute distance
and reference-object placement remain separate work, as do loft rails and centerlines.
## Document-opening performance ## Document-opening performance
+8
View File
@@ -202,6 +202,14 @@ fn sketch_world(ctx: &Ctx, sketch: u64, q: Vec2) -> Result<Vec3> {
fn sketch_curve_points(ctx: &Ctx, sketch: u64, curve: &str) -> Result<Vec<Vec3>> { fn sketch_curve_points(ctx: &Ctx, sketch: u64, curve: &str) -> Result<Vec<Vec3>> {
let ss = ctx.st.sketch(sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?; let ss = ctx.st.sketch(sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?;
if let Some(wire) = ss.sketch.wires.iter().find(|wire| wire.id == curve) {
if wire.pts.len() < 2 {
return Err(bad(format!("curve `{curve}` has no length")));
}
// Evaluation has already mapped wires into this component's frame. Their coordinates
// are independent of the sketch plane; applying it again would move the path twice.
return Ok(wire.pts.clone());
}
let ci = ss.sketch.curve_index(curve).ok_or_else(|| DocError::Unknown(format!("curve `{curve}`")))?; let ci = ss.sketch.curve_index(curve).ok_or_else(|| DocError::Unknown(format!("curve `{curve}`")))?;
let pl = ss.sketch.polyline(ci); let pl = ss.sketch.polyline(ci);
if pl.len() < 2 { if pl.len() < 2 {
+41
View File
@@ -216,6 +216,47 @@ fn plane_along_a_path() {
run(&mut s, "sketch.create", json!({"plane": "P", "name": "OnPath"})); run(&mut s, "sketch.create", json!({"plane": "P", "name": "OnPath"}));
} }
#[test]
fn construction_on_a_3d_wire_ignores_its_sketch_plane_and_follows_edits() {
let mut s = Session::default();
let sketch = run(&mut s, "sketch.create", json!({"plane":{"origin":[100,200,300],"x_dir":[0,1,0],"y_dir":[0,0,1]}}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.options", json!({"sketch_3d":true}));
let wire = run(&mut s, "sketch.line3d", json!({"points":[[10,20,10],[30,35,40]]}))["curves"][0].as_str().unwrap().to_string();
run(&mut s, "sketch.finish", json!({}));
let path = json!({"sketch":sketch,"curve":wire});
run(&mut s, "construct.plane.along_path", json!({"path":path,"t":0.5,"name":"BridgePlane"}));
run(&mut s, "construct.point.along_path", json!({"path":path,"t":0.25,"name":"Quarter"}));
let p = plane(&s, "BridgePlane");
assert!(near(p.origin, Vec3::new(20.0, 27.5, 25.0)), "{p:?}");
assert!(parallel(p.normal(), Vec3::new(20.0, 15.0, 30.0)));
assert!(near(point(&s, "Quarter"), Vec3::new(15.0, 23.75, 17.5)));
run(&mut s, "sketch.edit", json!({"sketch":sketch}));
run(&mut s, "sketch.move3d", json!({"wire":wire,"index":1,"to":[50,35,40]}));
run(&mut s, "sketch.finish", json!({}));
let updated = plane(&s, "BridgePlane");
assert!(near(updated.origin, Vec3::new(30.0, 27.5, 25.0)), "{updated:?}");
assert!(parallel(updated.normal(), Vec3::new(40.0, 15.0, 30.0)));
let restored = solvecraft_doc::format::read(&s.doc.to_json()).unwrap();
let mut cold = Session { doc: restored.into(), ..Default::default() };
cold.refresh();
assert!(near(plane(&cold, "BridgePlane").origin, updated.origin));
}
#[test]
fn plane_on_a_3d_wire_moves_with_its_component() {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name":"Part"}));
let sketch = run(&mut s, "sketch.create", json!({"plane":"XY"}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.options", json!({"sketch_3d":true}));
let wire = run(&mut s, "sketch.line3d", json!({"points":[[0,0,10],[0,0,40]]}))["curves"][0].as_str().unwrap().to_string();
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "construct.plane.along_path", json!({"path":{"sketch":sketch,"curve":wire},"t":0.5,"name":"Section"}));
run(&mut s, "occurrence.move", json!({"occurrence":"Part:1","translate":[50,20,0],"capture":true}));
let p = plane(&s, "Section");
assert!(near(p.origin, Vec3::new(50.0, 20.0, 25.0)), "{p:?}");
assert!(parallel(p.normal(), Vec3::Z));
}
/// Built from model geometry, they follow when the model changes: the box gets taller. /// Built from model geometry, they follow when the model changes: the box gets taller.
#[test] #[test]
fn construction_follows_the_model() { fn construction_follows_the_model() {
+64
View File
@@ -101,6 +101,11 @@ pub enum Kind {
AnglePlane { AnglePlane {
angle: String, angle: String,
}, },
/// A plane perpendicular to one selected path at a fractional distance.
PathPlane {
t: String,
sketch: Option<u64>,
},
/// Split a body by a plane. /// Split a body by a plane.
Split, Split,
/// Scale bodies uniformly. /// Scale bodies uniformly.
@@ -387,6 +392,9 @@ impl Dialog {
"construct.plane.offset" => { "construct.plane.offset" => {
Dialog::new(Kind::OffsetPlane { offset: "10 mm".into() }, vec![SelInput::new("Plane", PLANES | PLANAR_FACES, false)]) Dialog::new(Kind::OffsetPlane { offset: "10 mm".into() }, vec![SelInput::new("Plane", PLANES | PLANAR_FACES, false)])
} }
"construct.plane.along_path" => {
Dialog::new(Kind::PathPlane { t: "0.5".into(), sketch: None }, vec![SelInput::new("Path", CURVES | EDGES, false)])
}
"construct.plane.at_angle" => Dialog::new( "construct.plane.at_angle" => Dialog::new(
Kind::AnglePlane { angle: "45 deg".into() }, Kind::AnglePlane { angle: "45 deg".into() },
vec![SelInput::new("Plane", PLANES, false), SelInput::new("Axis", AXES, false)], vec![SelInput::new("Plane", PLANES, false), SelInput::new("Axis", AXES, false)],
@@ -588,6 +596,7 @@ impl Dialog {
| Kind::PatternCirc { .. } | Kind::PatternCirc { .. }
| Kind::Loft { .. } | Kind::Loft { .. }
| Kind::Sweep { .. } | Kind::Sweep { .. }
| Kind::PathPlane { .. }
| Kind::Move { .. } | Kind::Move { .. }
| Kind::Hole { .. } | Kind::Hole { .. }
| Kind::Primitive { .. } | Kind::Primitive { .. }
@@ -611,6 +620,7 @@ impl Dialog {
Kind::PatternCirc { angle, .. } => ("Angle", ValueKind::Angle, angle), Kind::PatternCirc { angle, .. } => ("Angle", ValueKind::Angle, angle),
Kind::Section { offset, .. } => ("Distance", ValueKind::Length, offset), Kind::Section { offset, .. } => ("Distance", ValueKind::Length, offset),
Kind::OffsetPlane { offset } => ("Distance", ValueKind::Length, offset), Kind::OffsetPlane { offset } => ("Distance", ValueKind::Length, offset),
Kind::PathPlane { t, .. } => ("Position (0 to 1)", ValueKind::Unitless, t),
Kind::Rib { thickness, .. } => ("Thickness", ValueKind::Length, thickness), Kind::Rib { thickness, .. } => ("Thickness", ValueKind::Length, thickness),
Kind::Emboss { depth, .. } => ("Depth", ValueKind::Length, depth), Kind::Emboss { depth, .. } => ("Depth", ValueKind::Length, depth),
Kind::PathPattern { spacing, .. } => ("Spacing", ValueKind::Length, spacing), Kind::PathPattern { spacing, .. } => ("Spacing", ValueKind::Length, spacing),
@@ -666,6 +676,10 @@ impl Dialog {
/// A pick in the viewport (already accepted by the active input). /// A pick in the viewport (already accepted by the active input).
pub fn pick(&mut self, s: &Session, sel: Sel) { pub fn pick(&mut self, s: &Session, sel: Sel) {
if let Kind::PathPlane { sketch, .. } = &mut self.kind {
*sketch = None;
self.extra.remove("path");
}
if self.active == 0 && matches!(self.kind, Kind::Extrude { .. } | Kind::Revolve { .. } | Kind::Emboss { .. }) { if self.active == 0 && matches!(self.kind, Kind::Extrude { .. } | Kind::Revolve { .. } | Kind::Emboss { .. }) {
self.profile_selection_changed = true; self.profile_selection_changed = true;
} }
@@ -709,6 +723,10 @@ impl Dialog {
/// Box selection result: replace (or add to) the active input. /// Box selection result: replace (or add to) the active input.
pub fn take_box(&mut self, s: &Session, sels: Vec<Sel>, add: bool) { pub fn take_box(&mut self, s: &Session, sels: Vec<Sel>, add: bool) {
if let Kind::PathPlane { sketch, .. } = &mut self.kind {
*sketch = None;
self.extra.remove("path");
}
if self.active == 0 && matches!(self.kind, Kind::Extrude { .. } | Kind::Revolve { .. } | Kind::Emboss { .. }) { if self.active == 0 && matches!(self.kind, Kind::Extrude { .. } | Kind::Revolve { .. } | Kind::Emboss { .. }) {
self.profile_selection_changed = true; self.profile_selection_changed = true;
} }
@@ -821,6 +839,7 @@ fn title(k: &Kind) -> &'static str {
Kind::Measure { .. } => "MEASURE", Kind::Measure { .. } => "MEASURE",
Kind::Material { .. } => "PHYSICAL MATERIAL", Kind::Material { .. } => "PHYSICAL MATERIAL",
Kind::OffsetPlane { .. } => "OFFSET PLANE", Kind::OffsetPlane { .. } => "OFFSET PLANE",
Kind::PathPlane { .. } => "PLANE ALONG PATH",
Kind::Rib { web: false, .. } => "RIB", Kind::Rib { web: false, .. } => "RIB",
Kind::Rib { web: true, .. } => "WEB", Kind::Rib { web: true, .. } => "WEB",
Kind::Emboss { .. } => "EMBOSS", Kind::Emboss { .. } => "EMBOSS",
@@ -1659,6 +1678,11 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
enter |= field(ui, angle); enter |= field(ui, angle);
ui.end_row(); ui.end_row();
} }
Kind::PathPlane { t, .. } => {
row_label(ui, "Position (0 to 1)");
enter |= field(ui, t);
ui.end_row();
}
Kind::Split | Kind::ReplaceFace | Kind::Remove => {} Kind::Split | Kind::ReplaceFace | Kind::Remove => {}
Kind::Rib { thickness, depth, flip, .. } => { Kind::Rib { thickness, depth, flip, .. } => {
row_label(ui, "Thickness"); row_label(ui, "Thickness");
@@ -2618,6 +2642,29 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
}; };
("construct.plane.offset", json!({"base": base, "offset": offset})) ("construct.plane.offset", json!({"base": base, "offset": offset}))
} }
Kind::PathPlane { t, sketch } => {
need(0, "a path")?;
let path = match sels(d, 0).first() {
Some(Sel::SketchCurve { id }) => {
let st = app.session.world_state();
let matches: Vec<u64> = st
.sketches
.iter()
.filter(|ss| ss.sketch.curve_index(id).is_some() || ss.sketch.wire_index(id).is_some())
.map(|ss| ss.feature)
.collect();
let source = match sketch {
Some(source) if matches.contains(source) => *source,
None if matches.len() == 1 => *matches.first().ok_or("pick a path")?,
_ => return Err("pick the path in the viewport to identify its source sketch".into()),
};
json!({"sketch": source, "curve": id})
}
Some(Sel::Edge { point, .. }) => d.extra.get("path").cloned().unwrap_or_else(|| json!({"edge": pt(*point)})),
_ => return Err("pick a sketch curve or model edge".into()),
};
("construct.plane.along_path", json!({"path": path, "t": t}))
}
Kind::AnglePlane { angle } => { Kind::AnglePlane { angle } => {
need(0, "a plane")?; need(0, "a plane")?;
let base = match sels(d, 0).first() { let base = match sels(d, 0).first() {
@@ -3538,6 +3585,23 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
} }
d d
} }
FeatureKind::ConstructionPlane { plane: PlaneRef::AlongPath { path, t } } => {
let mut d = start("construct.plane.along_path")?;
let (item, sketch) = match path.as_ref() {
solvecraft_engine::doc::construct::GeoRef::SketchCurve { sketch, curve } => (Sel::SketchCurve { id: curve.clone() }, Some(*sketch)),
solvecraft_engine::doc::construct::GeoRef::Edge { at, name } => {
// Preserve the saved named reference until a replacement is picked.
d.extra.insert("path".into(), json!({"type":"edge","at":pt3(*at),"name":name}));
(Sel::Edge { body: String::new(), index: 0, point: *at }, None)
}
_ => return None,
};
d.kind = Kind::PathPlane { t: t.clone(), sketch };
if let Some(input) = d.inputs.first_mut() {
input.items = vec![item];
}
d
}
FeatureKind::ConstructionPlane { plane: PlaneRef::AtAngle { base, axis_origin, axis_dir, angle } } => { FeatureKind::ConstructionPlane { plane: PlaneRef::AtAngle { base, axis_origin, axis_dir, angle } } => {
let mut d = start("construct.plane.at_angle")?; let mut d = start("construct.plane.at_angle")?;
d.kind = Kind::AnglePlane { angle: angle.clone() }; d.kind = Kind::AnglePlane { angle: angle.clone() };
+52
View File
@@ -8,6 +8,58 @@ use solvecraft_engine::{Sel, Session};
use crate::dialogs::{Dialog, Kind, apply_commands}; use crate::dialogs::{Dialog, Kind, apply_commands};
use crate::{Services, SolveApp}; use crate::{Services, SolveApp};
#[test]
fn path_plane_click_retains_source_and_previews_then_edits_in_place() {
let mut h = crate::scenario::Harness::new();
let source = h.app.run("sketch.create", json!({"plane":"XY","name":"Bridge"})).unwrap()["sketch"].as_u64().unwrap();
h.app.run("sketch.options", json!({"sketch_3d":true})).unwrap();
let wire = h.app.run("sketch.line3d", json!({"points":[[10,20,10],[30,35,40]]})).unwrap()["curves"][0].as_str().unwrap().to_string();
h.app.run("sketch.finish", json!({})).unwrap();
h.app.run("sketch.create", json!({"plane":"XY","name":"Other"})).unwrap();
h.app.run("sketch.options", json!({"sketch_3d":true})).unwrap();
let duplicate = h.app.run("sketch.line3d", json!({"points":[[-30,0,0],[-20,0,0]]})).unwrap()["curves"][0].as_str().unwrap().to_string();
assert_eq!(wire, duplicate, "exercise duplicate curve ids in different sketches");
h.app.run("sketch.finish", json!({})).unwrap();
h.app.ui.show_origin = false;
h.settle();
h.call("ui.view", json!({"view":"home"}));
h.call("ui.view", json!({"view":"fit"}));
h.app.start("construct.plane.along_path");
h.frames(2);
h.step(&json!({"click":{"world":[20,27.5,25]}})).unwrap();
let d = h.app.dialog.as_ref().unwrap();
let commands = apply_commands(&h.app, d).unwrap();
assert_eq!(commands[0].1["path"]["sketch"], json!(source));
let history = h.app.session.undo.len();
let count = h.app.session.doc.features.len();
let preview = h.app.session.preview_visible_world(&commands).unwrap();
let built = crate::preview::compute(&h.app.session, &commands, crate::preview::Colors::from_tokens(&crate::theme::Tokens::dark())).unwrap();
assert!(!built.scene.xray.is_empty(), "construction preview must contain visible geometry");
let plane = preview.after.construct.last().unwrap();
let solvecraft_engine::doc::construct::ConstructGeom::Plane(pl) = plane.geom else { panic!("plane") };
assert!(pl.origin.dist(Vec3::new(20.0, 27.5, 25.0)) < 1e-6);
assert_eq!(h.app.session.doc.features.len(), count);
assert_eq!(h.app.session.undo.len(), history);
h.step(&json!({"key":"Enter"})).unwrap();
assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog);
let feature = h.app.session.doc.features.last().unwrap().clone();
assert_eq!(h.app.session.undo.len(), history + 1);
h.app.edit_feature(feature.id);
h.frames(2);
let d = h.app.dialog.as_mut().unwrap();
assert!(matches!(d.kind,Kind::PathPlane{sketch:Some(s),..} if s==source));
if let Kind::PathPlane { t, .. } = &mut d.kind {
*t = "0.25".into();
}
h.step(&json!({"key":"Enter"})).unwrap();
assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog);
assert_eq!(h.app.session.doc.features.len(), count + 1);
let p = solvecraft_engine::view::construction_planes(&h.app.session).into_iter().find(|p| p.0 == feature.id).unwrap().2;
assert!(p.origin.dist(Vec3::new(15.0, 23.75, 17.5)) < 1e-6, "{p:?}");
h.app.run("edit.undo", json!({})).unwrap();
assert_eq!(h.app.session.doc.feature(feature.id).unwrap().kind, feature.kind);
}
#[test] #[test]
fn cylinder_emboss_empty_target_face_applies_automatic_wrap_and_deboss() { fn cylinder_emboss_empty_target_face_applies_automatic_wrap_and_deboss() {
for deboss in [false, true] { for deboss in [false, true] {
+23 -1
View File
@@ -186,7 +186,11 @@ fn spawn(pv: &mut PreviewState, key: u64, scratch: Session, cmds: Vec<(String, V
/// show their result in place of the bodies they change. When the feature fails (a boolean the /// show their result in place of the bodies they change. When the feature fails (a boolean the
/// kernel can't do yet), the tool body is still shown, with the error. /// kernel can't do yet), the tool body is still shown, with the error.
pub fn compute(s: &Session, cmds: &[(String, Value)], colors: Colors) -> Job { pub fn compute(s: &Session, cmds: &[(String, Value)], colors: Colors) -> Job {
if cmds.iter().any(|(command, _)| command == "sketch.redefine") { if cmds.iter().any(|(command, params)| {
command == "sketch.redefine"
|| command == "construct.plane.along_path"
|| params.get("command").and_then(Value::as_str) == Some("construct.plane.along_path")
}) {
let preview = s.preview_visible_world(cmds).map_err(|error| error.to_string())?; let preview = s.preview_visible_world(cmds).map_err(|error| error.to_string())?;
return std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { return std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let mut built = build(&preview.before, &preview.after, colors); let mut built = build(&preview.before, &preview.after, colors);
@@ -281,6 +285,24 @@ fn same(a: &Mesh, b: &Mesh) -> bool {
pub fn build(before: &ModelState, after: &ModelState, c: Colors) -> Built { pub fn build(before: &ModelState, after: &ModelState, c: Colors) -> Built {
let mut sc = GpuScene::default(); let mut sc = GpuScene::default();
let mut replaced = Vec::new(); let mut replaced = Vec::new();
for construct in &after.construct {
if before.construct.iter().any(|old| old == construct) {
continue;
}
if let solvecraft_engine::doc::construct::ConstructGeom::Plane(plane) = construct.geom {
let (o, x, y) = (plane.origin, plane.x * 10.0, plane.y * 10.0);
let quad = [o - x - y, o + x - y, o + x + y, o - x + y];
let color = [c.added[0], c.added[1], c.added[2], 64];
for triangle in [[quad[0], quad[1], quad[2]], [quad[0], quad[2], quad[3]]] {
for p in triangle {
sc.xray_tri(p.to_f32(), plane.normal().to_f32(), color);
}
}
for (a, b) in quad.iter().zip(quad.iter().cycle().skip(1)).take(4) {
sc.line(a.to_f32(), b.to_f32(), c.added, 1.3, true);
}
}
}
for a in &after.bodies { for a in &after.bodies {
let am = a.mesh(); let am = a.mesh();
let old = before.body(&a.name).map(|b| b.mesh()); let old = before.body(&a.name).map(|b| b.mesh());
+8 -1
View File
@@ -1809,10 +1809,17 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
if app.tool.is_some() { if app.tool.is_some() {
crate::tools::on_click(app, &proj, p); crate::tools::on_click(app, &proj, p);
} else if app.dialog.as_ref().is_some_and(|d| d.wants_picks()) { } else if app.dialog.as_ref().is_some_and(|d| d.wants_picks()) {
if let Some(sel) = cand.and_then(|c| c.1) if let Some((hit, Some(sel))) = cand
&& let Some(mut d) = app.dialog.take() && let Some(mut d) = app.dialog.take()
{ {
d.pick(&app.session, sel); d.pick(&app.session, sel);
if let crate::dialogs::Kind::PathPlane { sketch, .. } = &mut d.kind {
*sketch = match hit {
Hit::SketchCurve { sketch, .. } => Some(sketch),
_ => None,
};
d.extra.remove("path");
}
app.dialog = Some(d); app.dialog = Some(d);
} }
} else { } else {
+8 -1
View File
@@ -9,7 +9,7 @@ it.
|---|---| |---|---|
| Create | extrude (one side, two sides, symmetric, taper, start offset, to a distance or through all; new body, join, cut, intersect), revolve, sweep, loft, box, cylinder, sphere, torus, coil, pipe, rib, web, emboss, hole (simple, counterbore, countersink, tapped; on faces or at sketch points), thread, rectangular/circular/path patterns, mirror | | Create | extrude (one side, two sides, symmetric, taper, start offset, to a distance or through all; new body, join, cut, intersect), revolve, sweep, loft, box, cylinder, sphere, torus, coil, pipe, rib, web, emboss, hole (simple, counterbore, countersink, tapped; on faces or at sketch points), thread, rectangular/circular/path patterns, mirror |
| Modify | press pull, fillet, chamfer, shell, draft, scale, combine, split body, offset face, replace face, move/copy, align, remove | | Modify | press pull, fillet, chamfer, shell, draft, scale, combine, split body, offset face, replace face, move/copy, align, remove |
| Construct | offset plane, plane at angle | | Construct | offset plane, plane at angle, plane along path |
| Inspect | measure, section analysis, interference, curvature comb and map, zebra, draft, minimum radius, isocurves, centre of mass | | Inspect | measure, section analysis, interference, curvature comb and map, zebra, draft, minimum radius, isocurves, centre of mass |
Extrude automatically selects **Join** for additions when the active component already has a Extrude automatically selects **Join** for additions when the active component already has a
@@ -60,6 +60,13 @@ Feature references to faces and edges are stored by name, so an upstream edit th
keeps them on the right face (see [Persistent naming](../dev/naming.md)). When a reference can't keeps them on the right face (see [Persistent naming](../dev/naming.md)). When a reference can't
be found, the feature warns instead of silently picking another face. be found, the feature warns instead of silently picking another face.
**Plane Along Path** accepts one sketch curve (including a drawn 3D wire) or model edge.
Pick the path in the viewport and enter **Position (0 to 1)**: 0 starts the path, 1 ends it,
and 0.5 places the plane halfway along its length. The plane is perpendicular to the path's
local segment. Preview does not change the design; OK creates one editable timeline feature.
The plane follows later path edits. Curved paths use their sampled polyline; absolute distance,
reference-object placement and multi-curve chains are not yet available in this dialog.
## Editing a feature from a script ## Editing a feature from a script
For `solid.pattern.circular`, `axis` accepts `X`, `Y`, `Z`, or `{origin, dir}`. For `solid.pattern.circular`, `axis` accepts `X`, `Y`, `Z`, or `{origin, dir}`.