From b180dce1a18fe49c67dcc61c8a89f79fea6fbb6c Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Mon, 21 Sep 2026 17:57:58 +0200 Subject: [PATCH] feat(python): PEDIT polyline wrappers and path/3-D arrays polyline_close/open/width/reverse/join (a line or arc is turned into a polyline first), array_path and array_3d, written against observed prompts and checked for exact geometry in the real-IPC audit. Polylines default their pick point to the first vertex. The audit runs on a large-stack thread because PEDIT's line-to-polyline step needs more than a test thread's 2 MiB in a debug build. Co-Authored-By: Claude Sonnet 5 --- docs/plugin-host-model-completion-plan.md | 12 ++ plugins/opencad-python/PLUGIN.md | 11 +- plugins/opencad-python/src/document_model.py | 75 +++++++++++ .../tests/test_document_model.py | 16 +++ src/app/plugin_host.rs | 118 ++++++++++-------- 5 files changed, 175 insertions(+), 57 deletions(-) diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 475c529a3..4f7b05400 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -297,3 +297,15 @@ The generator now (1) refuses an unknown key in every nested record, naming the `audit_python_nested_input_is_validated_over_real_ipc` (eight refusals with the right messages for LwPolyline, Polyline2D and PolygonMesh; nothing invalid reaches the drawing; the valid forms still create), and every existing per-kind lifecycle audit still passes. +### 2026-09-21: PEDIT and the remaining arrays + +`doc.modify` gained `array_path`, `array_3d`, `polyline_close`, `polyline_open`, `polyline_width`, `polyline_reverse` and `polyline_join`, +written against the prompts observed in `spike_command_runner_prompts` (PEDIT offers to turn a line or arc into a polyline and the wrapper +accepts; the join option takes a selection, and its Enter completes the whole command). The default pick point for a polyline is its first +vertex. `audit_python_modify_wrappers_over_real_ipc` checks: closed and opened flags, the widened polyline, the reversed vertex order, the +joined polyline's three vertices with its line consumed, a line turned into a widened polyline (and the line gone), all five path-array +positions (2600 to 2650 in 12.5 steps), and all eight members of the 3-D array (x 2700/2720, y 0/10, z 0/30). Finding: PEDIT's line-to-polyline +step needs between 2 and 4 MiB of stack in a debug build, more than a test thread's 2 MiB, so that audit runs on a 64 MiB thread; the +application's main thread has 8 MiB, and a release build uses far less. Not wrapped: PEDIT fit, spline, decurve, linetype generation and +vertex editing. + diff --git a/plugins/opencad-python/PLUGIN.md b/plugins/opencad-python/PLUGIN.md index 4af823395..c02694cac 100644 --- a/plugins/opencad-python/PLUGIN.md +++ b/plugins/opencad-python/PLUGIN.md @@ -288,6 +288,11 @@ M.explode([polyline]); M.join([line1, line2]) M.break_entity(line, first_point, second_point) M.stretch(corner1, corner2, base, target) # crossing window, then the displacement M.lengthen(line, 5, at=near_the_end) # delta; negative shortens +M.array_path([a], path_curve, count=5) # evenly along a curve +M.array_3d([a], rows=2, columns=2, levels=2, row_spacing=10, column_spacing=20, level_spacing=30) +M.polyline_close(pl); M.polyline_open(pl); M.polyline_reverse(pl) +M.polyline_width(line_or_pl, 0.5) # a line or arc is turned into a polyline first +M.polyline_join(pl, [line1, arc1]) # returns the merged polyline ``` Each step returns an outcome dict: `status` (`completed` or `waiting_input`), @@ -302,7 +307,7 @@ the line leaves a prompt open. Selection-based tools (`move`, `copy`, `rotate`, `scale`, `mirror`, `erase`, the arrays, `explode` and `join`) select the entities you pass first, so the command skips its own selection prompt. Pick -points default to the middle of a line, arc or circle; give `at=` for anything else. +points default to the middle of a line, arc or circle and the first vertex of a polyline; give `at=` for anything else. `trim` and `extend` cut or extend against every entity in the drawing. Each command is undone as the editor would undo it. @@ -312,7 +317,9 @@ line names a command that could end the session or re-enter Python: `QUIT`, `RECOVER`, `SCRIPT`, `RUNSCRIPT` and any `PY_*`. Commands run on the active document only. Commands that need an interactive surface a script cannot fill (the rich-text editor, a file dialog) report it in `blocked_by` and are cancelled. -Hatching has no wrapper: create it with `create_entity("Hatch", ...)`, because the +PEDIT's fit, spline, decurve and vertex-edit options are not wrapped (reach them with +`start_command("PEDIT")`); `polyline_*` return the polyline entity, which is a new +entity when the command replaced the original. Hatching has no wrapper: create it with `create_entity("Hatch", ...)`, because the HATCH command needs an interactive boundary pick. `ocs.command_step(kind, options)` is the raw single step behind all of this. diff --git a/plugins/opencad-python/src/document_model.py b/plugins/opencad-python/src/document_model.py index 72f2456f4..81d77d5ed 100644 --- a/plugins/opencad-python/src/document_model.py +++ b/plugins/opencad-python/src/document_model.py @@ -678,6 +678,14 @@ class _Modify: return _pt(at) samples = ocs.curve_samples(_handle(entity), 4) if samples is None: + # A polyline: its first vertex lies on it. + data = ocs.entity_descriptor(_handle(entity)) or {} + vertices = data.get("vertices") + if vertices: + first = vertices[0] + where = first.get("location") or first.get("position") if isinstance(first, dict) else None + if where: + return [float(where["x"]), float(where["y"]), float(where.get("z", 0.0))] raise ValueError("pass at=(x, y, z): the middle of this kind of entity is not known") middle = samples["vertices"][len(samples["vertices"]) // 2] return [middle[0], middle[1], samples["elevation"]] @@ -821,6 +829,73 @@ class _Modify: lambda c: c.enter(), ]) + def array_path(self, entities, path, count, at=None): + """Array the entities along the curve `path`: `count` items counting the + original. Returns the copies.""" + return self._run("ARRAYPATH", [ + lambda c: c.entity(path, self._at(path, at)), + lambda c: c.text(count), + ], entities) + + def array_3d(self, entities, rows, columns, levels, row_spacing, column_spacing, level_spacing): + """Three-dimensional rectangular array (rows along Y, columns along X, levels + along Z); counts include the original. Returns the copies.""" + return self._run("ARRAY3D", [ + lambda c: c.text(rows), + lambda c: c.text(columns), + lambda c: c.text(levels), + lambda c: c.text(row_spacing), + lambda c: c.text(column_spacing), + lambda c: c.text(level_spacing), + ], entities) + + def _pedit(self, entity, steps, at=None): + """Run PEDIT on one entity: pick it, accept the offer to turn a line or arc + into a polyline, apply `steps`, then exit. Returns the polyline entity.""" + handle = _handle(entity) + before = list(ocs.entity_handles()) + with _Command("PEDIT") as command: + command.entity(handle, self._at(entity, at)) + if "Turn it into one" in command.outcome["prompt"]: + command.token("Y") + for step in steps: + step(command) + if command.waiting: + command.token("X") + _finished(command, "PEDIT") + if handle in set(ocs.entity_handles()): + return self._document.entities[handle] + added = self._added(before) + if not added: + raise RuntimeError("PEDIT left no polyline") + return added[0] + + def polyline_close(self, entity, at=None): + """Close an open polyline (a line or arc is first turned into one).""" + return self._pedit(entity, [lambda c: c.token("C")], at) + + def polyline_open(self, entity, at=None): + """Open a closed polyline.""" + return self._pedit(entity, [lambda c: c.token("O")], at) + + def polyline_width(self, entity, width, at=None): + """Give every segment of a polyline the same width.""" + return self._pedit(entity, [lambda c: c.token("W"), lambda c: c.text(width)], at) + + def polyline_reverse(self, entity, at=None): + """Reverse a polyline's direction.""" + return self._pedit(entity, [lambda c: c.token("R")], at) + + def polyline_join(self, entity, others, at=None): + """Join lines, arcs and polylines that meet the polyline's ends into it.""" + def join(c): + c.token("J") + self._select(others) + c.selection() + if c.waiting and "Join" in c.outcome["prompt"]: + c.enter() + return self._pedit(entity, [join], at) + def erase(self, entities): before = len(list(ocs.entity_handles())) self._select(entities) diff --git a/plugins/opencad-python/tests/test_document_model.py b/plugins/opencad-python/tests/test_document_model.py index 465cbe915..881014d83 100644 --- a/plugins/opencad-python/tests/test_document_model.py +++ b/plugins/opencad-python/tests/test_document_model.py @@ -58,6 +58,8 @@ class DocumentModelTests(unittest.TestCase): outcome = {"status": "completed", "blocked_by": None, "command": "", "prompt": "", "accepts": [], "options": [], "entities": 0, "added": 0, "unconsumed": [], "error": None} outcome.update(self.step_outcome.get(kind, {})) + if kind == "token" and options.get("text") == "X": + outcome["status"] = "completed" if kind in ("enter", "entity"): new = max(self.records) + 1 self.records[new] = {"handle": new, "kind": "Line", "layer": "0", "_editable": True} @@ -114,6 +116,20 @@ class DocumentModelTests(unittest.TestCase): self.doc.modify.stretch((0, 0), (1, 1), (0, 0), (5, 0)) self.assertEqual([c[1] for c in self.calls if c[0] == "step"], ["start", "point", "point", "enter", "point", "point"]) + def test_pedit_and_array_wrappers(self): + self.step_outcome = {"start": {"status": "waiting_input"}, "entity": {"status": "waiting_input", "prompt": "Turn it into one? [Yes/No]"}, + "token": {"status": "waiting_input"}} + self.doc.modify.polyline_close(1) + tokens = [c[2]["text"] for c in self.calls if c[0] == "step" and c[1] == "token"] + self.assertEqual(tokens, ["Y", "C", "X"]) + self.calls.clear() + self.step_outcome = {} + self.doc.modify.array_3d([1], 2, 2, 2, 10, 20, 30) + self.assertEqual([c[2]["text"] for c in self.calls if c[0] == "step" and c[1] == "text"], ["2", "2", "2", "10", "20", "30"]) + self.calls.clear() + self.doc.modify.array_path([1], 2, 5, at=(0, 0, 0)) + self.assertEqual([c[1] for c in self.calls if c[0] == "step"], ["start", "entity", "text"]) + def test_batch_edit_and_rollback(self): line = self.doc.entities[1] self.assertEqual(line.start, (1, 2, 3)) diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 95de91941..a94c9f01c 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -8009,6 +8009,17 @@ LT.delete('Temp') /// the real OCS command and the resulting geometry is checked exactly. #[test] fn audit_python_modify_wrappers_over_real_ipc() { + // PEDIT's line-to-polyline conversion runs deep inside a nested command step; a debug + // build needs more than the 2 MiB a test thread gets (the application's main thread has 8 MiB). + std::thread::Builder::new() + .stack_size(64 * 1024 * 1024) + .spawn(modify_wrappers_audit_body) + .unwrap() + .join() + .unwrap(); + } + + fn modify_wrappers_audit_body() { let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { return; }; @@ -8030,9 +8041,9 @@ LT.delete('Temp') " else:\n failed.append('ACCEPTED_' + tag)\n", "def step(tag, fn):\n", " try:\n return fn()\n except Exception as error:\n", - " failed.append(tag + '_' + ''.join(c if c.isalnum() else '_' for c in str(error))[:70])\n", + " failed.append(tag + '_' + ''.join(c if c.isalnum() else '_' for c in str(error))[:45])\n", "{body}\n", - "L.create('REPORT ' + str(len(refused)) + ' ~ ' + ' '.join(failed))\n", + "L.create(('REPORT ' + str(len(refused)) + ' ~ ' + ' '.join(failed))[:250])\n", ), body = body)).unwrap(); assert!(process.dispatch(host, &format!("PY_RUN {}", script.display()), &mut |_| {}).unwrap()); let _ = std::fs::remove_file(&script); @@ -8083,6 +8094,23 @@ st = line((1900, 0), (1910, 0)) step('stretch', lambda: M.stretch((1908, -2, 0), (1912, 2, 0), (1910, 0, 0), (1915, 0, 0))) ln = line((2000, 0), (2010, 0)) step('lengthen', lambda: M.lengthen(ln, 5, (2009, 0, 0))) +def lwp(pts, closed=False): return doc.create_entity('LwPolyline', is_closed=closed, vertices=[{'location': P(*p)} for p in pts]) +pc = lwp([(2100, 0), (2110, 0), (2110, 10)]) +step('polyline_close', lambda: M.polyline_close(pc)) +po = lwp([(2150, 0), (2160, 0), (2160, 10)], True) +step('polyline_open', lambda: M.polyline_open(po)) +pw = lwp([(2200, 0), (2210, 0), (2210, 10)]) +step('polyline_width', lambda: M.polyline_width(pw, 0.5)) +pr = lwp([(2300, 0), (2310, 0), (2310, 10)]) +step('polyline_reverse', lambda: M.polyline_reverse(pr)) +pj = lwp([(2400, 0), (2410, 0)]); pjl = line((2410, 0), (2420, 0)) +step('polyline_join', lambda: M.polyline_join(pj, [pjl])) +pline = line((2500, 0), (2510, 0)) +step('polyline_from_line', lambda: M.polyline_width(pline, 1)) +pb = line((2600, 0), (2601, 0)); pp = line((2600, 50), (2650, 50)) +step('array_path', lambda: M.array_path([pb], pp, 5)) +a3 = line((2700, 0), (2701, 0)) +step('array_3d', lambda: M.array_3d([a3], 2, 2, 2, 10, 20, 30)) step('run_line', lambda: doc.command('LINE 1100,0 1110,10')) def interactive(): with doc.start_command('CIRCLE') as c: @@ -8140,6 +8168,29 @@ check('bad_point', lambda: ocs.command_step('point', {'point': [0, 0, 0]})) assert!(has_line(1800.0, 0.0, 1802.0, 0.0) && has_line(1805.0, 0.0, 1810.0, 0.0) && !has_line(1800.0, 0.0, 1810.0, 0.0), "break: {:?}", describe()); assert!(has_line(1900.0, 0.0, 1915.0, 0.0), "stretch: {:?}", describe()); assert!(has_line(2000.0, 0.0, 2015.0, 0.0), "lengthen: {:?}", describe()); + let polylines: Vec = host.document().entities().filter_map(|e| match e { EntityType::LwPolyline(p) => Some(p.clone()), _ => None }).collect(); + let at = |x: f64| polylines.iter().find(|p| p.vertices.iter().any(|v| near(v.location.x, x))); + let pc = at(2100.0).expect("closed polyline"); + assert!(pc.is_closed, "polyline_close"); + assert!(!at(2150.0).expect("opened polyline").is_closed, "polyline_open"); + let pw = at(2200.0).expect("widened polyline"); + assert!(pw.constant_width == 0.5 || pw.vertices.iter().all(|v| v.start_width == 0.5 && v.end_width == 0.5), "polyline_width: {pw:?}"); + let pr = at(2300.0).expect("reversed polyline"); + assert_eq!((pr.vertices[0].location.x, pr.vertices[0].location.y), (2310.0, 10.0), "polyline_reverse: {pr:?}"); + let pj = polylines.iter().find(|p| p.vertices.iter().any(|v| near(v.location.x, 2420.0))).expect("joined polyline"); + assert_eq!(pj.vertices.len(), 3, "polyline_join: {pj:?}"); + assert!(!has_line(2410.0, 0.0, 2420.0, 0.0), "polyline_join consumed the line: {:?}", describe()); + let from_line = at(2500.0).expect("a line turned into a polyline"); + assert!(from_line.constant_width == 1.0 || from_line.vertices.iter().all(|v| v.start_width == 1.0), "polyline_from_line: {from_line:?}"); + assert!(!has_line(2500.0, 0.0, 2510.0, 0.0), "the line became the polyline: {:?}", describe()); + let path_copies: Vec<(f64, f64)> = lines.iter().filter(|l| l.start.x > 2599.0 && l.start.x < 2660.0).map(|l| (l.start.x, l.start.y)).collect(); + for x in [2600.0, 2612.5, 2625.0, 2637.5, 2650.0] { + assert!(path_copies.iter().any(|p| near(p.0, x) && near(p.1, 0.0)), "array_path item at x={x} (five evenly spaced along the 50-long path): {path_copies:?}"); + } + let three_d: Vec<(f64, f64, f64)> = lines.iter().filter(|l| l.start.x > 2699.0 && l.start.x < 2725.0).map(|l| (l.start.x, l.start.y, l.start.z)).collect(); + for x in [2700.0, 2720.0] { for y in [0.0, 10.0] { for z in [0.0, 30.0] { + assert!(three_d.iter().any(|p| near(p.0, x) && near(p.1, y) && near(p.2, z)), "array_3d member ({x},{y},{z}): {three_d:?}"); + } } } assert!(circles.iter().any(|c| near(c.center.x, 1200.0) && near(c.radius, 3.0)), "start_command session: {circles:?}"); assert!(host.app.tabs[0].active_cmd.is_none(), "no command is left running"); @@ -8234,64 +8285,21 @@ step('undo_move', lambda: M.move([u], (0, 0, 0), (7, 0, 0))) for l in lines { host.run_command(R::Run { line: (*l).into() }).unwrap(); } host.document().entities().map(|e| e.common().handle).collect() }; - let cancel = |host: &mut HostSession<'_>| { let _ = host.run_command(R::Cancel); }; - let hs = mk(&mut host, &["LINE 0,0 10,0", "LINE 0,0 0,10", "PLINE 50,0 60,0 60,10", "LINE 100,0 105,0", "LINE 105,0 110,5", "CIRCLE 200,0 5", "LINE 300,0 310,0"]); - // CHAMFER - run(&mut host, "CHAMFER start", R::Start { name: "CHAMFER".into() }); - run(&mut host, "CHAMFER D", R::Token { text: "D".into() }); - run(&mut host, "CHAMFER d1", R::Text { text: "2".into() }); - run(&mut host, "CHAMFER d2", R::Text { text: "3".into() }); - run(&mut host, "CHAMFER first", R::Entity { handle: hs[0], point: [8.0, 0.0, 0.0] }); - run(&mut host, "CHAMFER second", R::Entity { handle: hs[1], point: [0.0, 8.0, 0.0] }); - cancel(&mut host); - // ARRAYRECT - host.app.tabs[0].scene.replace_selection_exact(&[hs[6]]); - run(&mut host, "ARRAYRECT start", R::Start { name: "ARRAYRECT".into() }); - run(&mut host, "ARRAYRECT rows", R::Text { text: "2".into() }); - run(&mut host, "ARRAYRECT cols", R::Text { text: "3".into() }); - run(&mut host, "ARRAYRECT rowdist", R::Text { text: "10".into() }); - run(&mut host, "ARRAYRECT coldist", R::Text { text: "20".into() }); - run(&mut host, "ARRAYRECT enter", R::Enter); - cancel(&mut host); - // ARRAYPOLAR - host.app.tabs[0].scene.replace_selection_exact(&[hs[6]]); - run(&mut host, "ARRAYPOLAR start", R::Start { name: "ARRAYPOLAR".into() }); - run(&mut host, "ARRAYPOLAR center", R::Point { point: [300.0, 20.0, 0.0] }); - run(&mut host, "ARRAYPOLAR count", R::Text { text: "4".into() }); - run(&mut host, "ARRAYPOLAR angle", R::Text { text: "360".into() }); - run(&mut host, "ARRAYPOLAR enter", R::Enter); - cancel(&mut host); - // EXPLODE - run(&mut host, "EXPLODE start", R::Start { name: "EXPLODE".into() }); - run(&mut host, "EXPLODE entity", R::Entity { handle: hs[2], point: [55.0, 0.0, 0.0] }); - cancel(&mut host); - host.app.tabs[0].scene.replace_selection_exact(&[hs[2]]); - run(&mut host, "EXPLODE presel", R::Start { name: "EXPLODE".into() }); - cancel(&mut host); - // JOIN - host.app.tabs[0].scene.replace_selection_exact(&[hs[3], hs[4]]); - run(&mut host, "JOIN presel", R::Start { name: "JOIN".into() }); - run(&mut host, "JOIN enter", R::Enter); - cancel(&mut host); - // BREAK - run(&mut host, "BREAK start", R::Start { name: "BREAK".into() }); - run(&mut host, "BREAK entity", R::Entity { handle: hs[6], point: [302.0, 0.0, 0.0] }); - run(&mut host, "BREAK p2", R::Point { point: [305.0, 0.0, 0.0] }); - cancel(&mut host); - // HATCH - run(&mut host, "HATCH start", R::Start { name: "HATCH".into() }); - cancel(&mut host); - // STRETCH, LENGTHEN, JOIN, PEDIT - run(&mut host, "LENGTHEN start", R::Start { name: "LENGTHEN".into() }); - cancel(&mut host); + let hs = mk(&mut host, &["PLINE 2400,0 2410,0", "LINE 2410,0 2420,0"]); run(&mut host, "PEDIT start", R::Start { name: "PEDIT".into() }); - cancel(&mut host); - run(&mut host, "STRETCH start", R::Start { name: "STRETCH".into() }); - cancel(&mut host); + run(&mut host, "pick", R::Entity { handle: hs[0], point: [2400.0, 0.0, 0.0] }); + run(&mut host, "J", R::Token { text: "J".into() }); + host.app.tabs[0].scene.replace_selection_exact(&[hs[1]]); + run(&mut host, "selection", R::Selection); + run(&mut host, "enter", R::Enter); + run(&mut host, "X", R::Token { text: "X".into() }); eprintln!("SPIKE final entity count {}", host.document().entities().count()); } + + + #[test] fn audit_python_raster_image_definition_linkage_over_real_ipc() { use acadrust::objects::ObjectType;