diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index e9fb5aac2..e39ffa5f8 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -273,3 +273,14 @@ command, second command, run while active), no command left running, and a one-s Not covered: chamfer, arrays, hatch, join, explode, break and the many other commands (reachable through `doc.command` and `start_command`); reading prompts for commands with a text editor; an on/off setting for script-driven commands. +### 2026-09-21: more modify wrappers + +`doc.modify` gained `chamfer`, `array_rect`, `array_polar`, `explode`, `join`, `break_entity`, `stretch` and `lengthen`, each +written against the prompt sequence observed in `spike_command_runner_prompts` (STRETCH needs an Enter after the crossing +window, LENGTHEN and STRETCH end on Enter, EXPLODE and JOIN use the pre-selection because a pick at their selection prompt +selects nothing). `audit_python_modify_wrappers_over_real_ipc` checks exact geometry for each: the chamfer line, all six +rectangular-array members, the four polar-array members (start and half turn), the exploded segments and the removed polyline, +the joined line without its parts, the two pieces after a break, the stretched and the lengthened end points. HATCH is left to +`create_entity("Hatch")`: its command needs an interactive boundary pick that no scripted step can supply. Not wrapped: PEDIT, +path and 3-D arrays, and the remaining commands, which stay reachable through `doc.command` and `start_command`. + diff --git a/plugins/opencad-python/PLUGIN.md b/plugins/opencad-python/PLUGIN.md index 04e6b1b8c..4af823395 100644 --- a/plugins/opencad-python/PLUGIN.md +++ b/plugins/opencad-python/PLUGIN.md @@ -281,6 +281,13 @@ M.fillet(first, second, radius=2) # radius 0 squares the corner M.move([a, b], (0, 0, 0), (5, 5, 0)); M.copy([a], base, target) M.rotate([a], base, 90); M.scale([a], base, 2) M.mirror([a], p1, p2, erase_source=False); M.erase([a]) +M.chamfer(first, second, 2, 3) # distance 2 along first, 3 along second +M.array_rect([a], rows=2, columns=3, row_spacing=10, column_spacing=20) +M.array_polar([a], center, count=4, angle=360) # counts include the original +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 ``` Each step returns an outcome dict: `status` (`completed` or `waiting_input`), @@ -292,7 +299,8 @@ Each step returns an outcome dict: `status` (`completed` or `waiting_input`), also closing any editor or dialog it opened. `doc.command` cancels and raises when the line leaves a prompt open. -Selection-based tools (`move`, `copy`, `rotate`, `scale`, `mirror`, `erase`) select +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. `trim` and `extend` cut or extend against every entity in the drawing. Each command is @@ -304,7 +312,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. -`ocs.command_step(kind, options)` is the raw single step behind all of this. +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. ### Historical command-replay experiment (not in the default build) diff --git a/plugins/opencad-python/src/document_model.py b/plugins/opencad-python/src/document_model.py index 703352752..72f2456f4 100644 --- a/plugins/opencad-python/src/document_model.py +++ b/plugins/opencad-python/src/document_model.py @@ -751,6 +751,76 @@ class _Modify: lambda c: c.token("Y" if erase_source else "N"), ], entities) + def chamfer(self, first, second, distance1, distance2=None, at1=None, at2=None): + """Cut the corner between two lines; `distance1` is measured along `first` + and `distance2` (default the same) along `second`. Returns the new line.""" + if distance2 is None: + distance2 = distance1 + result = self._run("CHAMFER", [ + lambda c: c.token("D"), + lambda c: c.text(distance1), + lambda c: c.text(distance2), + lambda c: c.entity(first, self._at(first, at1)), + lambda c: c.entity(second, self._at(second, at2)), + ]) + if not result: + raise RuntimeError("CHAMFER produced nothing") + return result[0] + + def array_rect(self, entities, rows, columns, row_spacing, column_spacing): + """Rectangular array of the entities: `rows` by `columns` copies counting + the original, spaced by `row_spacing` and `column_spacing`. Returns the copies.""" + return self._run("ARRAYRECT", [ + lambda c: c.text(rows), + lambda c: c.text(columns), + lambda c: c.text(row_spacing), + lambda c: c.text(column_spacing), + ], entities) + + def array_polar(self, entities, center, count, angle=360): + """Polar array about `center`: `count` items counting the original, spread + over `angle` degrees. Returns the copies.""" + return self._run("ARRAYPOLAR", [ + lambda c: c.point(center), + lambda c: c.text(count), + lambda c: c.text(angle), + ], entities) + + def explode(self, entities): + """Break polylines, blocks and similar entities into their parts; returns the parts.""" + return self._run("EXPLODE", [], entities) + + def join(self, entities): + """Join end-to-end lines, arcs and polylines into one entity; returns it.""" + return self._run("JOIN", [], entities) + + def break_entity(self, entity, first, second): + """Remove the part of `entity` between two points on it; returns the new piece.""" + return self._run("BREAK", [ + lambda c: c.entity(entity, first), + lambda c: c.point(second), + ]) + + def stretch(self, corner1, corner2, base, target): + """Stretch whatever the crossing window (two opposite corners) catches, moving + `base` to `target`; entities fully inside move, ones crossing the edge stretch.""" + self._run("STRETCH", [ + lambda c: c.point(corner1), + lambda c: c.point(corner2), + lambda c: c.enter(), + lambda c: c.point(base), + lambda c: c.point(target), + ]) + + def lengthen(self, entity, delta, at=None): + """Lengthen (or, negative, shorten) `entity` by `delta` at the end nearest `at`.""" + self._run("LENGTHEN", [ + lambda c: c.token("DE"), + lambda c: c.text(delta), + lambda c: c.entity(entity, self._at(entity, at)), + lambda c: c.enter(), + ]) + 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 ada10cfe5..465cbe915 100644 --- a/plugins/opencad-python/tests/test_document_model.py +++ b/plugins/opencad-python/tests/test_document_model.py @@ -58,7 +58,7 @@ 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 == "enter": + if kind in ("enter", "entity"): new = max(self.records) + 1 self.records[new] = {"handle": new, "kind": "Line", "layer": "0", "_editable": True} return outcome @@ -98,6 +98,22 @@ class DocumentModelTests(unittest.TestCase): entity_step = [c for c in self.calls if c[0] == "step" and c[1] == "entity"][0] self.assertEqual(entity_step[2]["point"], [1, 0, 5.0]) + def test_more_modify_wrappers(self): + self.step_outcome = {} + self.doc.modify.chamfer(1, 2, 2, 3, at1=(0, 0, 0), at2=(1, 1, 0)) + texts = [c[2]["text"] for c in self.calls if c[0] == "step" and c[1] == "text"] + self.assertEqual(texts, ["2", "3"]) + self.calls.clear() + self.doc.modify.chamfer(1, 2, 4, at1=(0, 0, 0), at2=(1, 1, 0)) + texts = [c[2]["text"] for c in self.calls if c[0] == "step" and c[1] == "text"] + self.assertEqual(texts, ["4", "4"]) + self.calls.clear() + self.doc.modify.array_rect([1], 2, 3, 10, 20) + self.assertEqual([c[2]["text"] for c in self.calls if c[0] == "step" and c[1] == "text"], ["2", "3", "10", "20"]) + self.calls.clear() + 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_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 0ee70ef4c..50be57934 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -8067,6 +8067,22 @@ mi = line((800, 0), (810, 0)) step('mirror', lambda: M.mirror([mi], (0, 5, 0), (1, 5, 0))) e = line((900, 0), (910, 0)) step('erase', lambda: M.erase([e])) +ca = line((1300, 0), (1310, 0)); cb = line((1300, 0), (1300, 10)) +step('chamfer', lambda: M.chamfer(ca, cb, 2, 3, (1308, 0), (1300, 8))) +ar = line((1400, 0), (1401, 0)) +step('array_rect', lambda: M.array_rect([ar], 2, 3, 10, 20)) +ap = line((1500, 0), (1501, 0)) +step('array_polar', lambda: M.array_polar([ap], (1500, 10, 0), 4, 360)) +pl = doc.create_entity('LwPolyline', vertices=[{'location': P(1600, 0)}, {'location': P(1610, 0)}, {'location': P(1610, 10)}]) +step('explode', lambda: M.explode([pl])) +j1 = line((1700, 0), (1705, 0)); j2 = line((1705, 0), (1710, 0)) +step('join', lambda: M.join([j1, j2])) +bk = line((1800, 0), (1810, 0)) +step('break', lambda: M.break_entity(bk, (1802, 0, 0), (1805, 0, 0))) +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))) step('run_line', lambda: doc.command('LINE 1100,0 1110,10')) def interactive(): with doc.start_command('CIRCLE') as c: @@ -8112,6 +8128,18 @@ check('bad_point', lambda: ocs.command_step('point', {'point': [0, 0, 0]})) assert!(has_line(800.0, 0.0, 810.0, 0.0) && has_line(800.0, 10.0, 810.0, 10.0), "mirror keeps the source and adds the reflection: {:?}", describe()); assert!(!has_line(900.0, 0.0, 910.0, 0.0), "erase: {:?}", describe()); assert!(has_line(1100.0, 0.0, 1110.0, 10.0), "doc.command run: {:?}", describe()); + assert!(has_line(1302.0, 0.0, 1300.0, 3.0), "chamfer: {:?}", describe()); + for (x, y) in [(1400.0, 0.0), (1420.0, 0.0), (1440.0, 0.0), (1400.0, 10.0), (1420.0, 10.0), (1440.0, 10.0)] { + assert!(has_line(x, y, x + 1.0, y), "rectangular array member at ({x}, {y}): {:?}", describe()); + } + assert!(has_line(1500.0, 0.0, 1501.0, 0.0) && has_line(1500.0, 20.0, 1499.0, 20.0), "polar array (start and half turn): {:?}", describe()); + assert_eq!(lines.iter().filter(|l| l.start.x > 1450.0 && l.start.x < 1520.0 || l.end.x > 1450.0 && l.end.x < 1520.0).count(), 4, "polar array has four members"); + assert!(has_line(1600.0, 0.0, 1610.0, 0.0) && has_line(1610.0, 0.0, 1610.0, 10.0), "explode: {:?}", describe()); + assert!(!host.document().entities().any(|e| matches!(e, EntityType::LwPolyline(p) if p.vertices.first().is_some_and(|v| near(v.location.x, 1600.0)))), "explode removed the polyline"); + assert!(has_line(1700.0, 0.0, 1710.0, 0.0) && !has_line(1700.0, 0.0, 1705.0, 0.0), "join: {:?}", describe()); + 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()); 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"); @@ -8142,8 +8170,8 @@ step('undo_move', lambda: M.move([u], (0, 0, 0), (7, 0, 0))) app.tabs[0].is_start = false; let mut host = HostSession::new(&mut app, 0); let show = |label: &str, r: &Result| match r { - Ok(o) => eprintln!("SPIKE {label}: {} cmd={} prompt={:?} accepts={:?} opts={:?} added={} unconsumed={:?} err={:?}", - o.status, o.command, o.prompt, o.accepts, o.options, o.added, o.unconsumed, o.error), + Ok(o) => eprintln!("SPIKE {label}: {} prompt={:?} accepts={:?} opts={:?} added={} err={:?}", + o.status, o.prompt, o.accepts, o.options, o.added, o.error), Err(e) => eprintln!("SPIKE {label}: ERR {e}"), }; let run = |host: &mut HostSession<'_>, label: &str, r: R| { let out = host.run_command(r); show(label, &out); out }; @@ -8152,60 +8180,63 @@ step('undo_move', lambda: M.move([u], (0, 0, 0), (7, 0, 0))) 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 5,-5 5,5", "LINE 20,0 30,0", "LINE 0,10 10,10", "LINE 0,20 10,20"]); - // EXTEND: extend line 3 toward line 2? use boundary line 2 at x=5; extend line hs[0]'s end at x=10? Extend hs[2] (20..30) leftwards to x=5 no; use hs[0] end to the wall at x=15 - let _ = mk(&mut host, &["LINE 15,-5 15,5"]); - let wall = *host.document().entities().last().map(|e| &e.common().handle).unwrap(); - run(&mut host, "EXTEND start", R::Start { name: "EXTEND".into() }); - run(&mut host, "EXTEND pick", R::Entity { handle: hs[0], point: [9.0, 0.0, 0.0] }); - run(&mut host, "EXTEND enter", R::Enter); - cancel(&mut host); - // FILLET - run(&mut host, "FILLET start", R::Start { name: "FILLET".into() }); - run(&mut host, "FILLET R", R::Token { text: "R".into() }); - run(&mut host, "FILLET radius", R::Text { text: "2".into() }); - run(&mut host, "FILLET first", R::Entity { handle: hs[3], point: [2.0, 10.0, 0.0] }); - run(&mut host, "FILLET second", R::Entity { handle: hs[4], point: [2.0, 20.0, 0.0] }); - cancel(&mut host); - let _ = wall; + 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); - // selection commands - host.app.tabs[0].scene.replace_selection_exact(&[hs[3]]); - run(&mut host, "COPY run", R::Run { line: "COPY 0,0 0,3".into() }); - run(&mut host, "COPY leftover", R::Enter); - cancel(&mut host); - host.app.tabs[0].scene.replace_selection_exact(&[hs[3]]); - run(&mut host, "ROTATE run", R::Run { line: "ROTATE 0,0 90".into() }); - cancel(&mut host); - host.app.tabs[0].scene.replace_selection_exact(&[hs[4]]); - run(&mut host, "SCALE run", R::Run { line: "SCALE 0,0 2".into() }); - cancel(&mut host); - host.app.tabs[0].scene.replace_selection_exact(&[hs[4]]); - run(&mut host, "MIRROR start", R::Start { name: "MIRROR".into() }); - run(&mut host, "MIRROR sel", R::Selection); - run(&mut host, "MIRROR p1", R::Point { point: [0.0, 0.0, 0.0] }); - run(&mut host, "MIRROR p2", R::Point { point: [1.0, 0.0, 0.0] }); - run(&mut host, "MIRROR erase?", R::Token { text: "N".into() }); - cancel(&mut host); - host.app.tabs[0].scene.replace_selection_exact(&[hs[4]]); + // 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 sel", R::Selection); + 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); - host.app.tabs[0].scene.replace_selection_exact(&[hs[4]]); + // 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 sel", R::Selection); + 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, "ERASE run", R::Run { line: "ERASE".into() }); + run(&mut host, "EXPLODE presel", R::Start { name: "EXPLODE".into() }); cancel(&mut host); - host.app.tabs[0].scene.replace_selection_exact(&[hs[0]]); - run(&mut host, "EXPLODE run", R::Run { line: "EXPLODE".into() }); + // 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); + 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); eprintln!("SPIKE final entity count {}", host.document().entities().count()); } + #[test] fn audit_python_raster_image_definition_linkage_over_real_ipc() { use acadrust::objects::ObjectType;