Files
autocad-mcp-pro/engineering/sheet/bom.py
T
Umut CANandClaude Opus 5.5 898e775f93 bench: matrix v5 adds mech_assembly; the correctness suite grows with the mechanical and sheet checks
The evidence for tracks B and G, in the shape tracks A and E used.

- benchmarks/tasks_v5.py: 18 tasks, DEFAULT_MATRIX = "v5"; v4, v3 and v2 stay
  addressable so an earlier report reproduces exactly rather than being only
  described. mech_assembly is roadmap criterion 3 - a flange-coupling sheet
  built from the part model and the sheet standard, through the code the
  tools call (apply_titleblock, draw_part/add_view/dimension_part,
  insert_std_part, extract_records -> draw_bom_table, add_balloon), gated on
  two numbers that cannot be negotiated: drawing_critique(focus=None) returns
  zero issues and the finalize score is at least 90 (it scores 100, 18/18 on
  the v5 matrix). The adapter returns the focuses that fired, so a
  regression names itself, and a test drops one balloon to prove the gate
  fails and names mech_bom_balloon_mismatch.
- Seven correctness checks (32 -> 39). mech_section_hatch_area is the one
  that cannot be faked: a sleeve of length 60, outside diameter 40 and bore
  20 cuts two faces totalling 1200 mm^2 exactly, read back out of the drawing
  through entity_measure. sheet_frame_iso5457_a3 pins the A3 frame (20,10 to
  410,287 inside the 420 x 297 trimmed sheet); mech_iso286_on_dimension pins
  40 H7 at +0.025/0 through the layout; std_part_iso4014_m12 pins s = 18 and
  k = 7.5 by value; mech_thread_unrepresented_is_caught is the seventh: a
  quality gate that never fires is not a gate.
- scripts/smoke_mech_com.py executed every new COM path once on AutoCAD 2026
  (25.1s): frame, title block, a sectioned shaft with 12 dimensions, an ISO
  4014 block with 1 ACADMCP_MECH chunk, a weld symbol, the parts list read off
  the drawing, a linked balloon, an xref attached / listed / detached, a
  31687-byte .dwg (AC1032) through ActiveX SaveAs, critique 0 issues, exit 0,
  every scratch document closed. It retries RPC_E_CALL_REJECTED (measured:
  the first call after Documents.Add is refused) and tracks its documents by
  the document list, since SaveAs renames them.
- Defects the evidence found, each fixed with a test that fails on the old
  code:
  * ezdxf dimension_diameter put the text beyond the SECOND chord point;
    ActiveX AddDimDiametric puts it beyond the first (measured live: a d50
    from (x,170) to (x,220), leader 10, text at (x,160)). Headlessly every
    front-view diameter stood on its own chain dimension. ezdxf now follows
    ActiveX.
  * draw_part's anchor goes on MECH; ActiveX refuses entity.Layer on a
    missing layer (measured -0x7ffdfff7 'Key not found'). draw.py ensures
    every target layer before the first entity.
  * bom.extract_records filtered on INSERT; ActiveX names it BLOCKREFERENCE
    (ObjectName AcDbBlockReference) - the live parts list was empty.
  * COM _entity_info reported no dimension text_position: ModelSpace.Item
    narrows to IAcadEntity, which has no TextPosition (measured
    AttributeError); read through _com_unnarrow (TextPosition measured on
    AcDbDiametricDimension, WCS). dim_overlap compared bbox centres and a
    diameter collided with its own bore.
  * pid graph raised KeyError 'start' mid-critique on a LINE whose point read
    AutoCAD refused while busy; such a line is left out.
  * the validator read "ISO 4014 - M12x60" as fake dimension text.
- The published A/B report is re-run over the grown suite rather than
  re-typed (26/39 -> 39/39, 13 fixed, 0 regressed), check_doc_numbers derives
  the task count from the v5 matrix, the token table is remeasured with a
  TOOL_PACKS=core,mech row (193 tools / 61,673 tokens), and the README's
  collected-tests figure is retaken (3990).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-23 13:30:02 +03:00

604 lines
23 KiB
Python

"""ISO 7573 parts lists and ISO 6433 item references.
SOURCE
------
Parts list ISO 7573:2008 -- an item list carries at least an item reference
(the position number), a quantity, a designation and a reference
to the document or standard that defines the item. When the list
is drawn on the drawing it is placed directly above the title
block, has the same width as it, and the item numbers ascend
upwards so the list can be extended -- which puts the heading row
at the bottom, against the title block. `direction="down"` draws
the same list the other way up, for a list on its own sheet.
Balloons ISO 6433:2012 -- an item reference is a numeral placed outside the
outline of the item and connected to it by a leader line that
terminates in a dot on the item.
NOT TRANSCRIBED, and therefore not asserted anywhere as if it were the
standard's:
* The column widths. ISO 7573 fixes neither the widths nor the order; the
shares below total the 180 mm title-block width, which *is* the standard's
rule, and any column set is rescaled to that total.
* The row height. 7 mm is this repository's `entity_create_table` default.
* ISO 6433's rule that the numeral is at least twice the height of the
dimension characters. The numeral here is 1.4 * the balloon radius, so the
default radius of 4 mm gives a 5.6 mm numeral -- which clears 2 x 2.5 mm
dimension text but not 2 x 3.5 mm. A drawing dimensioned at 3.5 mm should
pass `radius=5.0`, and the docstring of `balloon_prims` says so.
"""
from __future__ import annotations
import math
from typing import TYPE_CHECKING
from engineering.mech.primitives import Circle, Line, Prim, Pt, Text
from engineering.mech.xdata import APP_ID, PAYLOAD_VERSION, decode, to_values
from engineering.sheet.frames import SHEET_TEXT_LAYER, draw_sheet_prims, enter_layout
if TYPE_CHECKING:
from backends.base import AutoCADBackend
__all__ = [
"BALLOON_DOT_RADIUS",
"BOM_COLUMNS",
"COLUMN_LABELS",
"COLUMN_SHARES",
"DEFAULT_COLUMNS",
"DIRECTIONS",
"LIST_STANDARDS",
"LIST_WIDTH",
"PAGE_SIZE",
"ROW_HEIGHT",
"add_balloon",
"all_entities",
"balloon_prims",
"draw_bom_table",
"extract_records",
"read_balloons",
"rows_from_records",
"table_layout",
"table_prims",
]
LIST_WIDTH = 180.0 # ISO 7573: the item list is as wide as the title block.
ROW_HEIGHT = 7.0
TEXT_HEIGHT = 2.5
BALLOON_DOT_RADIUS = 0.5
DIRECTIONS = ("up", "down")
LIST_STANDARDS = ("ISO 7573",)
#: Every column this module knows how to print, in the order it prints them.
BOM_COLUMNS: tuple[str, ...] = (
"item",
"qty",
"designation",
"standard",
"material",
"mass",
"remarks",
)
#: The four ISO 7573 minimum columns plus the material this shop always wants.
DEFAULT_COLUMNS: tuple[str, ...] = ("item", "qty", "designation", "standard", "material")
COLUMN_LABELS: dict[str, str] = {
"item": "POS",
"qty": "QTY",
"designation": "DESIGNATION",
"standard": "STANDARD",
"material": "MATERIAL",
"mass": "MASS",
"remarks": "REMARKS",
}
#: Relative widths; `table_layout` rescales the chosen set to LIST_WIDTH. The
#: default five happen to total 180.0, so their scale factor is exactly 1.
COLUMN_SHARES: dict[str, float] = {
"item": 12.0,
"qty": 14.0,
"designation": 74.0,
"standard": 46.0,
"material": 34.0,
"mass": 22.0,
"remarks": 40.0,
}
#: How many entities one `entity_list` call asks for while paging. It is a page
#: size, never a cap: `all_entities` keeps asking until a short page comes back.
PAGE_SIZE = 1000
#: A block reference under either engine's type name: the DXF name headlessly,
#: the ActiveX ObjectName (AcDbBlockReference) live.
INSERT_TYPES = ("INSERT", "BLOCKREFERENCE")
async def all_entities(backend: AutoCADBackend, *, type_filter=None, layer=None):
"""Every entity of a type in the CURRENT space -- not the first 200 of them.
`entity_list`'s `limit` defaults to 200 on both engines, so a single call is
a silent truncation on any real drawing: a parts list that stops at the
200th INSERT prints a wrong QTY, and a duplicate-balloon guard that only
sees the first 200 CIRCLEs is blind on a drawing whose hole pattern fills
them. Everything in this module that *counts* or *guards* reads through
here instead.
The loop stops on a short page, and also when a page brings nothing new --
a backend that ignored `offset` would otherwise hand back the same page for
ever. Handles are unique per drawing, so that guard cannot drop a real
entity.
"""
found: list = []
seen: set[str] = set()
offset = 0
while True:
page = await backend.entity_list(
type_filter=type_filter, layer_filter=layer, limit=PAGE_SIZE, offset=offset
)
fresh = [entity for entity in page if entity.handle not in seen]
if not fresh:
return tuple(found)
seen.update(entity.handle for entity in fresh)
found.extend(fresh)
if len(page) < PAGE_SIZE:
return tuple(found)
offset += len(page)
def _check_columns(columns) -> tuple[str, ...]:
chosen = tuple(DEFAULT_COLUMNS if columns is None else columns)
unknown = [c for c in chosen if c not in BOM_COLUMNS]
if unknown:
raise ValueError(
f"unknown parts-list column(s) {', '.join(unknown)}; "
f"available: {', '.join(BOM_COLUMNS)}"
)
if "item" not in chosen:
raise ValueError("ISO 7573: the item reference column 'item' cannot be dropped")
return chosen
def rows_from_records(records, *, columns=None, group_by: str | None = "designation"):
"""Collapse extracted INSERT records into numbered parts-list rows.
`group_by` is a record key (`"designation"`, `"standard"`, ...) or None for
one row per insert. Rows are sorted by the grouping value so a rerun on the
same drawing numbers the same parts the same way; `handles` carries every
INSERT the row stands for, which is what a balloon links to.
"""
chosen = _check_columns(columns)
if group_by is not None and group_by not in BOM_COLUMNS:
raise ValueError(
f"group_by must be None or one of {', '.join(BOM_COLUMNS)}; got {group_by!r}"
)
buckets: dict[str, dict] = {}
order: list[str] = []
for index, record in enumerate(records):
key = str(record.get(group_by, "")) if group_by is not None else f"#{index:06d}"
bucket = buckets.get(key)
if bucket is None:
bucket = {c: record.get(c, "") for c in chosen if c != "item"}
bucket["qty"] = 0
bucket["handles"] = []
bucket["_key"] = key
buckets[key] = bucket
order.append(key)
bucket["qty"] += int(record.get("qty", 1) or 1)
handle = record.get("handle")
if handle:
bucket["handles"].append(str(handle))
if group_by is not None:
order.sort()
rows: list[dict] = []
for item, key in enumerate(order, start=1):
bucket = buckets[key]
bucket.pop("_key", None)
bucket["handles"] = tuple(bucket["handles"])
bucket["item"] = item
rows.append({c: bucket.get(c, "") for c in chosen} | {"handles": bucket["handles"]})
return tuple(rows)
def table_layout(
rows,
*,
at: Pt,
direction: str = "up",
columns=None,
row_height: float = ROW_HEIGHT,
):
"""Everything both renderers need, so the prims and the real TABLE agree.
`at` is the anchor: the list's bottom-left corner for `direction="up"` (it
sits on the title block) and its top-left corner for `direction="down"`.
`insert` is where `entity_create_table` must start, since a TABLE is drawn
downwards from its insertion point.
"""
if direction not in DIRECTIONS:
raise ValueError(f"direction must be one of {DIRECTIONS}, got {direction!r}")
chosen = _check_columns(columns)
if row_height <= 0:
raise ValueError(f"row_height must be positive, got {row_height!r}")
raw = [COLUMN_SHARES[c] for c in chosen]
scale = LIST_WIDTH / sum(raw)
widths = [w * scale for w in raw]
heading = [COLUMN_LABELS[c] for c in chosen]
body = [[str(row.get(c, "")) for c in chosen] for row in rows]
cells = list(reversed(body)) + [heading] if direction == "up" else [heading] + body
height = row_height * len(cells)
ax, ay = float(at[0]), float(at[1])
insert = (ax, ay + height) if direction == "up" else (ax, ay)
return {
"columns": chosen,
"labels": heading,
"widths": widths,
"width": sum(widths),
"height": height,
"row_height": row_height,
"direction": direction,
"anchor": (ax, ay),
"insert": insert,
"cells": cells,
}
def table_prims(
rows,
*,
at: Pt,
direction: str = "up",
standard: str = "ISO 7573",
height: float = ROW_HEIGHT,
) -> tuple[Prim, ...]:
"""The parts list as primitive descriptions, for a caller that does not
want a real TABLE (and for the tests that pin the layout)."""
if standard not in LIST_STANDARDS:
raise ValueError(
f"parts-list standard {standard!r} is not transcribed; "
f"available: {', '.join(LIST_STANDARDS)}"
)
layout = table_layout(rows, at=at, direction=direction, row_height=height)
ax, ay = layout["anchor"]
total_w, total_h = layout["width"], layout["height"]
prims: list[Prim] = []
for index in range(len(layout["cells"]) + 1):
y = ay + index * layout["row_height"]
prims.append(Line(p1=(ax, y), p2=(ax + total_w, y)))
x = ax
prims.append(Line(p1=(x, ay), p2=(x, ay + total_h)))
for width in layout["widths"]:
x += width
prims.append(Line(p1=(x, ay), p2=(x, ay + total_h)))
pad = min(1.0, layout["row_height"] * 0.15)
for row_index, cells in enumerate(layout["cells"]):
# cells are top-to-bottom; the top row is the last one up the sheet
baseline = ay + (len(layout["cells"]) - row_index - 1) * layout["row_height"] + pad
x = ax
for column_index, value in enumerate(cells):
if value:
prims.append(Text(at=(x + pad, baseline), text=value, height=TEXT_HEIGHT))
x += layout["widths"][column_index]
return tuple(prims)
def balloon_prims(
item: int,
at: Pt,
leader_to: Pt,
*,
radius: float = 4.0,
height: float | None = None,
) -> tuple[Prim, ...]:
"""One ISO 6433 item reference: the balloon, the leader, the dot, the numeral.
The numeral is 1.4 * `radius` high. ISO 6433 asks for at least twice the
dimension character height, so a sheet dimensioned at 3.5 mm wants
`radius=5.0` (a 7 mm numeral); the 4 mm default clears 2.5 mm dimensions.
"""
number = int(item)
if number <= 0:
raise ValueError(f"an ISO 6433 item reference is a positive integer; got {item!r}")
if radius <= 0:
raise ValueError(f"balloon radius must be positive; got {radius!r}")
text_height = float(radius) * 1.4 if height is None else float(height)
cx, cy = float(at[0]), float(at[1])
tx, ty = float(leader_to[0]), float(leader_to[1])
distance = math.hypot(tx - cx, ty - cy)
if distance <= radius:
raise ValueError(
f"balloon {number}: the leader target ({tx}, {ty}) is inside the balloon at "
f"({cx}, {cy}) r={radius}; ISO 6433 puts the reference outside the item"
)
ux, uy = (tx - cx) / distance, (ty - cy) / distance
label = str(number)
return (
Circle(center=(cx, cy), radius=float(radius)),
Line(p1=(cx + ux * radius, cy + uy * radius), p2=(tx, ty)),
Circle(center=(tx, ty), radius=BALLOON_DOT_RADIUS),
Text(
at=(cx - 0.3 * text_height * len(label), cy - 0.5 * text_height),
text=label,
height=text_height,
),
)
#: Block attributes read when an INSERT carries no ACADMCP_MECH payload.
_ATTRIBUTE_KEYS = {
"designation": ("DESIGNATION", "DESIG", "NAME", "TAG"),
"standard": ("STANDARD", "STD", "NORM"),
"material": ("MATERIAL", "MAT"),
"size": ("SIZE",),
"qty": ("QTY", "QUANTITY"),
"remarks": ("REMARKS", "NOTE"),
}
def _from_attributes(attributes: dict) -> dict:
upper = {str(k).upper(): v for k, v in (attributes or {}).items()}
record: dict = {}
for field, keys in _ATTRIBUTE_KEYS.items():
for key in keys:
if key in upper and str(upper[key]).strip():
record[field] = str(upper[key]).strip()
break
return record
async def extract_records(backend: AutoCADBackend, *, layer=None, limit: int | None = None):
"""Walk the INSERTs and return one parts-list record per block reference.
Reads only: the entity listing, each INSERT's attributes and its
ACADMCP_MECH payload. Nothing is written, so this is safe on a drawing that
is not ours. `source` is "xdata" when the payload supplied the row and
"attributes" when the block's ATTRIBs did; a block with neither is skipped
rather than guessed at.
`limit` is None by default and the whole drawing is read, by paging
`entity_list` rather than taking its 200-entity default: a parts list that
stops at the 200th INSERT does not print a shorter list, it prints a wrong
QTY, and nothing on the sheet says so. A caller that wants a cap passes one
and reads `len(records)` back against it -- `bom_extract` asks for one more
record than the cap so it can report `truncated` exactly.
"""
if limit is not None and int(limit) <= 0:
raise ValueError(f"bom_extract: limit must be a positive number of records; got {limit!r}")
# MEASURED on AutoCAD 2026 by scripts/smoke_mech_com.py: the live engine
# names a block reference by its ObjectName, BLOCKREFERENCE, and filters on
# that name, so a filter on the DXF name INSERT found nothing and the parts
# list came back empty on a sheet holding an ISO 4014 bolt. Both names are
# read, the way engineering/pid/graph.py already does.
inserts: list = []
seen: set[str] = set()
for type_name in INSERT_TYPES:
for entity in await all_entities(backend, type_filter=type_name, layer=layer):
if entity.handle not in seen:
seen.add(entity.handle)
inserts.append(entity)
records: list[dict] = []
for entity in inserts:
payload: dict = {}
try:
raw = await backend.entity_get_xdata(entity.handle, APP_ID)
payload = decode(raw.get("xdata", {}).get(APP_ID, [])) or {}
except (ValueError, KeyError, TypeError):
payload = {}
if payload.get("kind") != "std_part":
payload = {}
# Measured on both engines: `block_get_attributes` returns the flat
# ``{tag: value}`` mapping itself, not ``{"attributes": {...}}``
# (ezdxf_backend.py:6687 builds it from ``ent.attribs``, com_backend.py:5703
# from ``ref.GetAttributes()``). Reading a wrapper key here would have
# made every attribute fallback silently empty.
attributes = {}
try:
attributes = await backend.block_get_attributes(entity.handle)
except Exception: # noqa: BLE001 - a block with no ATTRIBs is not an error here
attributes = {}
fallback = _from_attributes(attributes)
designation = str(payload.get("designation") or fallback.get("designation") or "").strip()
if not designation:
continue
records.append(
{
"handle": entity.handle,
"block": entity.properties.get("block_name", ""),
"layer": entity.layer,
"designation": designation,
"standard": str(payload.get("standard") or fallback.get("standard") or ""),
"size": str(payload.get("size") or fallback.get("size") or ""),
"material": str(payload.get("material") or fallback.get("material") or ""),
"mass": str(payload.get("mass") or ""),
"remarks": str(payload.get("remarks") or fallback.get("remarks") or ""),
"qty": int(payload.get("qty") or fallback.get("qty") or 1),
"source": "xdata" if payload else "attributes",
}
)
if limit is not None and len(records) >= int(limit):
break
return tuple(records)
async def draw_bom_table(
backend: AutoCADBackend,
rows,
*,
at: Pt,
direction: str = "up",
columns=None,
row_height: float = ROW_HEIGHT,
layer: str = SHEET_TEXT_LAYER,
layout: str | None = None,
) -> dict:
"""Draw the parts list as a real TABLE entity.
`entity_create_table` grows downwards from its insertion point, so an
upward-growing ISO 7573 list is drawn from an insertion one table height
above the anchor, with the heading as the last (bottom) row.
`representation` is reported rather than assumed: the live engine draws a
real ACAD_TABLE, while the headless one returns a *composite* whose
``handle`` is its first child rule (``tests/test_table_leader.py`` pins
that contract). A caller that means to edit the table afterwards has to
know which of the two it got.
"""
if not rows:
raise ValueError("bom_table: there are no parts-list rows to draw")
spec = table_layout(rows, at=at, direction=direction, columns=columns, row_height=row_height)
previous = await enter_layout(backend, layout, caller="bom_table")
try:
entity = await backend.entity_create_table(
spec["insert"][0],
spec["insert"][1],
spec["cells"],
headers=None,
column_widths=spec["widths"],
row_height=spec["row_height"],
text_height=TEXT_HEIGHT,
layer=layer,
)
finally:
if layout is not None:
await backend.layout_set_current(previous)
ax, ay = spec["anchor"]
return {
"ok": True,
"handle": entity.handle,
"entity_type": entity.type,
"representation": entity.properties.get("representation", "native"),
"child_handles": list(entity.properties.get("child_handles") or []),
"standard": "ISO 7573",
"direction": direction,
"columns": list(spec["columns"]),
"rows": len(rows),
"insert": spec["insert"],
"layout": layout or "Model",
"bbox": {"min": [ax, ay], "max": [ax + spec["width"], ay + spec["height"]]},
}
async def read_balloons(backend: AutoCADBackend):
"""Every ISO 6433 balloon this server drew in the CURRENT space, from its
ACADMCP_MECH payload.
Paged, not capped: a drawing whose first 200 CIRCLEs are a hole pattern --
routine -- used to read back no balloons at all, which made every guard in
`add_balloon` silently pass.
"""
found: list[dict] = []
for entity in await all_entities(backend, type_filter="CIRCLE"):
try:
raw = await backend.entity_get_xdata(entity.handle, APP_ID)
payload = decode(raw.get("xdata", {}).get(APP_ID, [])) or {}
except (ValueError, KeyError, TypeError):
continue
if payload.get("kind") != "balloon":
continue
found.append(
{
"handle": entity.handle,
"item": int(payload.get("item", 0)),
"targets": list(payload.get("targets") or []),
"text": payload.get("text", ""),
}
)
found.sort(key=lambda row: row["item"])
return tuple(found)
async def add_balloon(
backend: AutoCADBackend,
*,
item: int,
at: Pt,
leader_to: Pt,
targets=(),
radius: float = 4.0,
layer: str = "DIM",
layout: str | None = None,
) -> dict:
"""Draw (or renumber) one ISO 6433 item reference.
A balloon already pointing at the same targets is *renumbered* -- its
numeral is edited and its payload rewritten -- so running the balloon pass
again after the parts list is regrouped does not litter the sheet with
duplicates. An item number already on the sheet is refused before anything
is drawn unless it is that renumber: ISO 129-1 and ISO 6433 both want one
reference per item.
Both guards read the space the balloon is going ON. `read_balloons` lists
the current space, so reading it before `enter_layout` inspected Model
while the balloons lived on the layout -- and the layout is the sheet
workflow, so the guards were off exactly where they matter.
"""
number = int(item)
wanted = tuple(sorted(str(t) for t in targets))
prims = balloon_prims(number, at, leader_to, radius=radius) # validates first
previous = await enter_layout(backend, layout, caller="balloon_add")
try:
existing = await read_balloons(backend)
for balloon in existing:
if wanted and tuple(sorted(balloon["targets"])) == wanted:
if balloon["text"]:
await backend.entity_edit_text(balloon["text"], text=str(number))
payload = {
"v": PAYLOAD_VERSION,
"kind": "balloon",
"item": number,
"targets": list(wanted),
"text": balloon["text"],
}
await backend.entity_set_xdata(balloon["handle"], APP_ID, to_values(payload))
return {
"ok": True,
"renumbered": True,
"item": number,
"handle": balloon["handle"],
"text": balloon["text"],
"targets": list(wanted),
"layout": layout or "Model",
}
# Anything still carrying this number is a clash: the renumber arm above
# has already returned for the one balloon that stands for these
# targets. Comparing the target tuples here instead let two untargeted
# balloons -- `targets` is optional in the tool, so `() != ()` -- carry
# the same item number with no refusal at all.
clash = next((b for b in existing if b["item"] == number), None)
if clash is not None:
has_targets = f"targets {clash['targets']}" if clash["targets"] else "no targets"
raise ValueError(
f"item reference {number} is already used by balloon {clash['handle']} "
f"({has_targets}); ISO 6433 gives each item one reference. Pass that "
"balloon's targets to renumber it in place, or use a free number."
)
handles = await draw_sheet_prims(
backend, prims, layer_map={"visible": layer, "text": layer, "center": layer}
)
payload = {
"v": PAYLOAD_VERSION,
"kind": "balloon",
"item": number,
"targets": list(wanted),
"text": handles[3],
}
await backend.entity_set_xdata(handles[0], APP_ID, to_values(payload))
return {
"ok": True,
"renumbered": False,
"item": number,
"handle": handles[0],
"leader": handles[1],
"dot": handles[2],
"text": handles[3],
"targets": list(wanted),
"layout": layout or "Model",
}
finally:
if layout is not None:
await backend.layout_set_current(previous)