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 <[email protected]>
This commit is contained in:
Felix RiestraandClaude Sonnet 5 committed 2026-09-21 17:57:58 +02:00
1 parent 81251d9cf3
commit b180dce1a1
5 files changed
+175 -57

No files matched your search

+9 -2
View File
@@ -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.
@@ -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)
@@ -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))