feat(python): chamfer, arrays, explode, join, break, stretch and lengthen wrappers

Each is written against the prompt sequence observed in the exploration harness
and checked for exact geometry in the real-IPC audit. HATCH is left to
create_entity because its command needs an interactive boundary pick.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Felix RiestraandClaude Sonnet 5 committed 2026-09-21 17:34:18 +02:00
1 parent 1b1785eefb
commit 360dd853ed
5 files changed
+184 -46

No files matched your search

+11
View File
@@ -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 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. `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`.
+12 -2
View File
@@ -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.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.rotate([a], base, 90); M.scale([a], base, 2)
M.mirror([a], p1, p2, erase_source=False); M.erase([a]) 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`), 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 also closing any editor or dialog it opened. `doc.command` cancels and raises when
the line leaves a prompt open. 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 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; give `at=` for anything else.
`trim` and `extend` cut or extend against every entity in the drawing. Each command is `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 `RECOVER`, `SCRIPT`, `RUNSCRIPT` and any `PY_*`. Commands run on the active
document only. Commands that need an interactive surface a script cannot fill 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. (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) ### Historical command-replay experiment (not in the default build)
@@ -751,6 +751,76 @@ class _Modify:
lambda c: c.token("Y" if erase_source else "N"), lambda c: c.token("Y" if erase_source else "N"),
], entities) ], 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): def erase(self, entities):
before = len(list(ocs.entity_handles())) before = len(list(ocs.entity_handles()))
self._select(entities) self._select(entities)
@@ -58,7 +58,7 @@ class DocumentModelTests(unittest.TestCase):
outcome = {"status": "completed", "blocked_by": None, "command": "", "prompt": "", "accepts": [], outcome = {"status": "completed", "blocked_by": None, "command": "", "prompt": "", "accepts": [],
"options": [], "entities": 0, "added": 0, "unconsumed": [], "error": None} "options": [], "entities": 0, "added": 0, "unconsumed": [], "error": None}
outcome.update(self.step_outcome.get(kind, {})) outcome.update(self.step_outcome.get(kind, {}))
if kind == "enter": if kind in ("enter", "entity"):
new = max(self.records) + 1 new = max(self.records) + 1
self.records[new] = {"handle": new, "kind": "Line", "layer": "0", "_editable": True} self.records[new] = {"handle": new, "kind": "Line", "layer": "0", "_editable": True}
return outcome 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] 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]) 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): def test_batch_edit_and_rollback(self):
line = self.doc.entities[1] line = self.doc.entities[1]
self.assertEqual(line.start, (1, 2, 3)) self.assertEqual(line.start, (1, 2, 3))
+74 -43
View File
@@ -8067,6 +8067,22 @@ mi = line((800, 0), (810, 0))
step('mirror', lambda: M.mirror([mi], (0, 5, 0), (1, 5, 0))) step('mirror', lambda: M.mirror([mi], (0, 5, 0), (1, 5, 0)))
e = line((900, 0), (910, 0)) e = line((900, 0), (910, 0))
step('erase', lambda: M.erase([e])) 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')) step('run_line', lambda: doc.command('LINE 1100,0 1110,10'))
def interactive(): def interactive():
with doc.start_command('CIRCLE') as c: 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(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(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(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!(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"); 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; app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0); let mut host = HostSession::new(&mut app, 0);
let show = |label: &str, r: &Result<ocs_plugin_api::host::CommandOutcome, String>| match r { let show = |label: &str, r: &Result<ocs_plugin_api::host::CommandOutcome, String>| match r {
Ok(o) => eprintln!("SPIKE {label}: {} cmd={} prompt={:?} accepts={:?} opts={:?} added={} unconsumed={:?} err={:?}", Ok(o) => eprintln!("SPIKE {label}: {} prompt={:?} accepts={:?} opts={:?} added={} err={:?}",
o.status, o.command, o.prompt, o.accepts, o.options, o.added, o.unconsumed, o.error), o.status, o.prompt, o.accepts, o.options, o.added, o.error),
Err(e) => eprintln!("SPIKE {label}: ERR {e}"), 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 }; 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() host.document().entities().map(|e| e.common().handle).collect()
}; };
let cancel = |host: &mut HostSession<'_>| { let _ = host.run_command(R::Cancel); }; 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"]); 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"]);
// 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;
// CHAMFER // CHAMFER
run(&mut host, "CHAMFER start", R::Start { name: "CHAMFER".into() }); 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); cancel(&mut host);
// selection commands // ARRAYRECT
host.app.tabs[0].scene.replace_selection_exact(&[hs[3]]); host.app.tabs[0].scene.replace_selection_exact(&[hs[6]]);
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]]);
run(&mut host, "ARRAYRECT start", R::Start { name: "ARRAYRECT".into() }); 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); 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 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); cancel(&mut host);
host.app.tabs[0].scene.replace_selection_exact(&[hs[2]]); 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); cancel(&mut host);
host.app.tabs[0].scene.replace_selection_exact(&[hs[0]]); // JOIN
run(&mut host, "EXPLODE run", R::Run { line: "EXPLODE".into() }); 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); cancel(&mut host);
eprintln!("SPIKE final entity count {}", host.document().entities().count()); eprintln!("SPIKE final entity count {}", host.document().entities().count());
} }
#[test] #[test]
fn audit_python_raster_image_definition_linkage_over_real_ipc() { fn audit_python_raster_image_definition_linkage_over_real_ipc() {
use acadrust::objects::ObjectType; use acadrust::objects::ObjectType;