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 <[email protected]>
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
`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.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)
@@ -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)
@@ -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))
+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)))
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<ocs_plugin_api::host::CommandOutcome, String>| 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;