master
John Lauer Publish 1.2.1 8b75307 18d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629
#!/usr/bin/env python3
"""Pure-text STEP transforms (no OpenCascade): the mark planner and the etch run on the shared services,
and the only local geometry work they need is exact and small enough to do on the STEP text itself.

  * rotate the whole model by a multiple of 90 degrees about X, Y or Z (stand a Y-up model up, turn a
    side face to the top, pose the part so its mark reads along +X): every 3-D CARTESIAN_POINT and
    DIRECTION is permuted and negated, which is exact, and conjugates every assembly placement, so
    colours, names and the assembly tree are untouched;
  * move one product's geometry (the Adom.LaserEtch mark) by turn + uniform scale + translation about a
    centre: its own points, directions and VECTOR magnitudes change, pcurve (2-D) points scale with it,
    and entities shared with other products (the frame origin) are left alone;
  * read a product's points per face (glyph centroids and extents for the legibility gate).

Python API: Step(path_or_text) .rotate90(axis, quarter_turns) .transform_product(name, ...) .text
CLI:  step_text.py rotate in.step out.step <x|y|z> <quarter_turns>
      step_text.py products in.step"""
import math, re, sys

_ENT = re.compile(r"#(\d+)\s*=\s*", re.S)
_REF = re.compile(r"#(\d+)")


def _split_statements(data):
    """Split the DATA section into statements at ';' outside quoted strings."""
    out, buf, i, n, inq = [], [], 0, len(data), False
    start = 0
    while i < n:
        c = data[i]
        if inq:
            if c == "'":
                if i + 1 < n and data[i + 1] == "'":
                    i += 2; continue
                inq = False
        elif c == "'":
            inq = True
        elif c == ";":
            out.append(data[start:i]); start = i + 1
        i += 1
    tail = data[start:]
    return out, tail


def fmt(v):
    """A STEP REAL: always with a decimal point, exponent as E."""
    if v == 0:
        return "0."
    s = repr(float(v))
    if "e" in s or "E" in s:
        m, e = s.lower().split("e")
        if "." not in m:
            m += "."
        return f"{m}E{int(e):+03d}".replace("E+", "E+")
    return s if "." in s else s + "."


def _nums(body):
    """The numbers of the coordinate list in CARTESIAN_POINT('n',(a,b,c)) / DIRECTION('n',(a,b,c))."""
    m = re.search(r"\(\s*'(?:[^']|'')*'\s*,\s*\(([^()]*)\)\s*\)\s*$", body, re.S)
    if not m:
        return None, None
    return [float(t) for t in m.group(1).replace("\n", " ").split(",")], m.span(1)


class Step:
    def __init__(self, src):
        text = src if "ISO-10303-21" in src[:200] else open(src, encoding="latin-1").read()
        i = text.index("DATA;") + 5
        j = text.index("ENDSEC;", i)
        self.head, self.tail = text[:i], text[j:]
        stmts, rest = _split_statements(text[i:j])
        self.order, self.body, self.lead = [], {}, {}
        for s in stmts:
            m = _ENT.search(s)
            if not m:
                continue
            k = int(m.group(1))
            self.order.append(k); self.body[k] = s[m.end():].strip(); self.lead[k] = s[:m.start()]
        self.rest = rest
        self._rev = None

    # ---- graph -------------------------------------------------------------------------------
    def kind(self, k):
        b = self.body[k]
        return b[: b.index("(")].strip() if not b.startswith("(") else ""

    def refs(self, k):
        return [int(x) for x in _REF.findall(re.sub(r"'(?:[^']|'')*'", "", self.body[k]))]

    def rev(self):
        if self._rev is None:
            r = {}
            for k in self.order:
                for x in self.refs(k):
                    r.setdefault(x, []).append(k)
            self._rev = r
        return self._rev

    def reach(self, roots, stop=()):
        seen, st = set(), list(roots)
        while st:
            k = st.pop()
            if k in seen or k not in self.body or k in stop:
                continue
            seen.add(k); st.extend(self.refs(k))
        return seen

    def products(self):
        out = []
        for k in self.order:
            if self.kind(k) == "PRODUCT":
                m = re.match(r"PRODUCT\s*\(\s*'((?:[^']|'')*)'", self.body[k])
                out.append((k, m.group(1).replace("''", "'") if m else ""))
        return out

    def _up(self, k, kind_prefix):
        return [x for x in self.rev().get(k, []) if self.kind(x).startswith(kind_prefix) and self.kind(x) == kind_prefix]

    def product_reps(self, pid):
        """Representations (and SRR-linked breps) that carry this product's own geometry."""
        reps = []
        for pdf in self._up(pid, "PRODUCT_DEFINITION_FORMATION") + self._up(pid, "PRODUCT_DEFINITION_FORMATION_WITH_SPECIFIED_SOURCE"):
            for pd in self._up(pdf, "PRODUCT_DEFINITION"):
                for pds in self._up(pd, "PRODUCT_DEFINITION_SHAPE"):
                    for sdr in self._up(pds, "SHAPE_DEFINITION_REPRESENTATION"):
                        reps.append(self.refs(sdr)[-1])
        more = list(reps)
        while more:
            r = more.pop()
            for x in self.rev().get(r, []):
                if self.kind(x) == "SHAPE_REPRESENTATION_RELATIONSHIP":
                    a = self.refs(x)
                    for y in a:
                        if y != r and y not in reps:
                            reps.append(y); more.append(y)
        return reps

    def _rep_items(self, rep):
        """Items of a representation, without its context (last ref)."""
        r = self.refs(rep)
        return r[:-1]

    def product_entities(self, name, pids=None):
        """Entities that belong only to the named product's geometry (shared frames and contexts excluded)."""
        prods = self.products()
        mine = list(pids) if pids else [p for p, nm in prods if nm == name]
        if not mine:
            return None
        my_reps = set(r for p in mine for r in self.product_reps(p))
        other_reps = set(r for p, nm in prods if p not in mine for r in self.product_reps(p)) - my_reps
        roots_m = [i for r in my_reps for i in self._rep_items(r)]
        roots_o = [i for r in other_reps for i in self._rep_items(r)]
        # anything referenced from outside the product reps (assembly placements, styling) is shared too
        a = self.reach(roots_m)
        b = self.reach(roots_o)
        shared = set()
        for k in self.order:
            if k in a or k in b:
                continue
            kd = self.kind(k)
            if kd == "ITEM_DEFINED_TRANSFORMATION":      # assembly placements: frames shared with the parent
                shared |= self.reach(self.refs(k))
        return a - b - shared

    # ---- numeric access ----------------------------------------------------------------------
    def coords(self, k):
        return _nums(self.body[k])[0]

    def set_coords(self, k, vals):
        nums, span = _nums(self.body[k])
        b = self.body[k]
        self.body[k] = b[: span[0]] + ",".join(fmt(v) for v in vals) + b[span[1]:]

    def faces_points(self, ents):
        """For each ADVANCED_FACE (or FACE_SURFACE) in `ents`, in file order: its 3-D boundary points."""
        out = []
        for k in self.order:
            if k in ents and self.kind(k) in ("ADVANCED_FACE", "FACE_SURFACE"):
                pts = []
                fb = [x for x in self.refs(k) if self.kind(x) in ("FACE_BOUND", "FACE_OUTER_BOUND")]
                for x in self.reach(fb):
                    if self.kind(x) == "CARTESIAN_POINT":
                        c = self.coords(x)
                        if c and len(c) == 3:
                            pts.append(c)
                out.append(dict(id=k, points=pts))
        return out

    # ---- transforms --------------------------------------------------------------------------
    # ---- frames and flattening ---------------------------------------------------------------
    def _new_id(self):
        self._next = max(getattr(self, "_next", 0), max(self.order)) + 1
        return self._next

    def _add(self, body, after=None):
        k = self._new_id(); self.body[k] = body; self.lead[k] = ""
        self.order.append(k); self._rev = None
        return k

    def rep_frames(self):
        """AXIS2_PLACEMENT_3D entities listed directly among a representation's items (its own frame and
        the frames assembly transforms point at)."""
        out = set()
        for k in self.order:
            if self.kind(k).endswith("REPRESENTATION") and "SHAPE" in self.kind(k) or self.kind(k) == "SHAPE_REPRESENTATION":
                for x in self._rep_items(k):
                    if x in self.body and self.kind(x) == "AXIS2_PLACEMENT_3D":
                        out.add(x)
        return out

    def _frame(self, x):
        o, z, xd = [self.coords(r) for r in self.refs(x)[:3]] if len(self.refs(x)) >= 3 else (self.coords(self.refs(x)[0]), [0, 0, 1], [1, 0, 0])
        def nrm(v):
            l = math.sqrt(sum(c * c for c in v)) or 1.0
            return [c / l for c in v]
        z = nrm(z); xd = [xd[i] - sum(xd[j] * z[j] for j in range(3)) * z[i] for i in range(3)]; xd = nrm(xd)
        y = [z[1] * xd[2] - z[2] * xd[1], z[2] * xd[0] - z[0] * xd[2], z[0] * xd[1] - z[1] * xd[0]]
        return o, [xd, y, z]          # columns: local X, Y, Z in the parent

    @staticmethod
    def _mat(F):
        o, (X, Y, Z) = F
        return [[X[0], Y[0], Z[0], o[0]], [X[1], Y[1], Z[1], o[1]], [X[2], Y[2], Z[2], o[2]], [0, 0, 0, 1]]

    @staticmethod
    def _mul(A, B):
        return [[sum(A[i][k] * B[k][j] for k in range(4)) for j in range(4)] for i in range(4)]

    @staticmethod
    def _inv(M):
        R = [[M[j][i] for j in range(3)] for i in range(3)]
        t = [-sum(R[i][k] * M[k][3] for k in range(3)) for i in range(3)]
        return [R[0] + [t[0]], R[1] + [t[1]], R[2] + [t[2]], [0, 0, 0, 1]]

    @staticmethod
    def _is_identity(M, tol=1e-9):
        return all(abs(M[i][j] - (1.0 if i == j else 0.0)) < tol for i in range(4) for j in range(4))

    def _isolate_frames(self):
        """Give every representation frame its own point and directions, so geometry never shares them."""
        for f in self.rep_frames():
            refs = self.refs(f)
            b = self.body[f]
            for r in refs:
                nk = self._add(self.body[r])
                b = re.sub(rf"#{r}(?!\d)", f"#{nk}", b, count=1)
            self.body[f] = b
        self._rev = None
        return self.rep_frames()

    def _frame_ents(self, frames):
        return set(x for f in frames for x in self.reach([f]))

    def flatten(self):
        """Bake assembly transforms (and a root representation's own frame, which OpenCascade applies as
        a placement) into the geometry and reset every frame to the identity. Afterwards local == world,
        so turning the part and moving the mark are plain coordinate edits. Refuses instanced parts."""
        frames = self._isolate_frames()
        fents = self._frame_ents(frames)
        rels = []                           # (child rep, parent rep, child frame, parent frame)
        for k in self.order:
            b = self.body[k]
            if self.kind(k) == "" and "REPRESENTATION_RELATIONSHIP_WITH_TRANSFORMATION" in b:
                m = re.search(r"REPRESENTATION_RELATIONSHIP\s*\(\s*'(?:[^']|'')*'\s*,\s*'(?:[^']|'')*'\s*,\s*#(\d+)\s*,\s*#(\d+)", b)
                t = re.search(r"REPRESENTATION_RELATIONSHIP_WITH_TRANSFORMATION\s*\(\s*#(\d+)", b)
                if m and t and self.kind(int(t.group(1))) == "ITEM_DEFINED_TRANSFORMATION":
                    a1, a2 = self.refs(int(t.group(1)))[:2]
                    rels.append((int(m.group(1)), int(m.group(2)), a1, a2))
        if any(self.kind(k) in ("MAPPED_ITEM", "REPRESENTATION_MAP") for k in self.order):
            raise SystemExit("step_text: MAPPED_ITEM instancing is not supported; pass a flattened STEP")
        parent = {}
        for c, p, a1, a2 in rels:
            if c in parent and parent[c][0] != p:
                raise SystemExit("step_text: an instanced sub-shape (used twice) cannot be flattened as text")
            T = self._mul(self._mat(self._frame(a2)), self._inv(self._mat(self._frame(a1))))
            parent[c] = (p, T)
        reps = [k for k in self.order if (self.kind(k).endswith("REPRESENTATION") and "SHAPE" in self.kind(k)) or self.kind(k) == "SHAPE_REPRESENTATION"]
        reps = [r for r in reps if self.kind(r) not in ("SHAPE_DEFINITION_REPRESENTATION", "CONTEXT_DEPENDENT_SHAPE_REPRESENTATION")]
        # SRR-linked reps (shape rep <-> its brep) share the frame of their partner
        srr = {}
        for k in self.order:
            if self.kind(k) == "SHAPE_REPRESENTATION_RELATIONSHIP":
                r = self.refs(k)
                if len(r) >= 2:
                    srr.setdefault(r[1], r[0]); srr.setdefault(r[0], r[1])
        def own_frame(r):
            fs = [x for x in self._rep_items(r) if x in frames]
            return self._mat(self._frame(fs[0])) if fs else None
        def world(r, depth=0):
            if depth > 32:
                raise SystemExit("step_text: assembly loop")
            if r in parent:
                p, T = parent[r]
                return self._mul(world(p, depth + 1), T)
            if r in srr and srr[r] in parent:
                return world(srr[r], depth + 1)
            F = own_frame(r)
            if r in srr and F is None:
                F = own_frame(srr[r])
            # a root frame that no transform uses is applied by OpenCascade as the root placement
            used = set(x for _, _, a1, a2 in rels for x in (a1, a2))
            fs = [x for x in self._rep_items(r) if x in frames]
            if F is not None and not (fs and fs[0] in used):
                return F
            return [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]
        owner = {}
        for r in reps:
            M = world(r)
            geo = self.reach([x for x in self._rep_items(r) if x not in frames]) - fents
            for k in geo:
                if k in owner and owner[k][0] != r and not self._is_identity(owner[k][1]) or (k in owner and owner[k][0] != r and not self._is_identity(M)):
                    if self.kind(k) in ("CARTESIAN_POINT", "DIRECTION"):
                        raise SystemExit("step_text: geometry shared between placed parts; pass a flattened STEP")
                owner.setdefault(k, (r, M))
        for k, (r, M) in owner.items():
            if self._is_identity(M):
                continue
            kd = self.kind(k)
            if kd in ("CARTESIAN_POINT", "DIRECTION"):
                c = self.coords(k)
                if c and len(c) == 3:
                    if kd == "CARTESIAN_POINT":
                        v = [sum(M[i][j] * c[j] for j in range(3)) + M[i][3] for i in range(3)]
                    else:
                        v = [sum(M[i][j] * c[j] for j in range(3)) for i in range(3)]
                    self.set_coords(k, v)
        ident = {0: [0.0, 0.0, 0.0], 1: [0.0, 0.0, 1.0], 2: [1.0, 0.0, 0.0]}
        for f in frames:
            for i, r in enumerate(self.refs(f)[:3]):
                self.set_coords(r, ident[i])
        self._flat = True
        return self

    def rotate90(self, axis, q):
        """Rotate the whole model by q quarter turns (counter-clockwise looking down +axis). The file is
        flattened first (frames at identity), then only geometry turns, so every STEP reader agrees."""
        q %= 4
        if not getattr(self, "_flat", False):
            self.flatten()
        if q == 0:
            return self
        def rot(v):
            x, y, z = v
            for _ in range(q):
                if axis == "z": x, y = -y, x
                elif axis == "x": y, z = -z, y
                elif axis == "y": z, x = -x, z
            return [x, y, z]
        fents = self._frame_ents(self.rep_frames())
        for k in self.order:
            kd = self.kind(k)
            if kd in ("CARTESIAN_POINT", "DIRECTION") and k not in fents:
                c = self.coords(k)
                if c and len(c) == 3:
                    self.set_coords(k, rot(c))
        return self

    def transform_product(self, name, centre, scale=1.0, quarter_turns_z=0, translate=(0, 0, 0), set_z=None):
        """p' = centre + scale * Rz(q) (p - centre) + translate  for the product's own points; optional
        set_z replaces the z of every 3-D point (a flat mark seated on its face). 2-D (pcurve) points scale
        about the origin, directions turn, VECTOR magnitudes scale."""
        if not getattr(self, "_flat", False):
            self.flatten()
        ents = self.product_entities(name)
        if ents is not None:
            ents -= self._frame_ents(self.rep_frames())
        if not ents:
            raise SystemExit(f"step_text: no product named {name!r}")
        q = quarter_turns_z % 4
        def rz(x, y):
            for _ in range(q):
                x, y = -y, x
            return x, y
        cx, cy, cz = centre
        for k in ents:
            kd = self.kind(k)
            if kd == "CARTESIAN_POINT":
                c = self.coords(k)
                if c is None:
                    continue
                if len(c) == 3:
                    x, y = rz(c[0] - cx, c[1] - cy)
                    z = c[2] - cz
                    nz = (set_z if set_z is not None else cz + scale * z + translate[2])
                    self.set_coords(k, [cx + scale * x + translate[0], cy + scale * y + translate[1], nz])
                elif len(c) == 2:
                    self.set_coords(k, [scale * c[0], scale * c[1]])
            elif kd == "DIRECTION":
                c = self.coords(k)
                if c and len(c) == 3 and q:
                    x, y = rz(c[0], c[1]); self.set_coords(k, [x, y, c[2]])
            elif kd == "VECTOR" and scale != 1.0:
                m = re.match(r"(VECTOR\s*\(\s*'(?:[^']|'')*'\s*,\s*#\d+\s*,\s*)([^)]+)(\)\s*)$", self.body[k], re.S)
                if m:
                    self.body[k] = m.group(1) + fmt(float(m.group(2)) * scale) + m.group(3)
            elif kd in ("CIRCLE", "CYLINDRICAL_SURFACE", "CONICAL_SURFACE", "SPHERICAL_SURFACE", "ELLIPSE", "TOROIDAL_SURFACE") and scale != 1.0:
                raise SystemExit(f"step_text: product {name!r} has a {kd}; scaling it is not supported (flat glyph marks are lines and B-splines)")
        return self

    def faces_subset_entities(self, name, face_ids):
        """Entities that belong to the given faces of a product and to no other face of it."""
        ents = self.product_entities(name) - self._frame_ents(self.rep_frames())
        allf = [k for k in ents if self.kind(k) in ("ADVANCED_FACE", "FACE_SURFACE")]
        mine = self.reach(face_ids) & ents
        others = self.reach([f for f in allf if f not in face_ids]) & ents
        return mine - others

    def transform_entities(self, ents, centre, scale=1.0, translate=(0, 0, 0), set_z=None):
        """Uniform scale about centre + translation for a set of entities (one line of a mark)."""
        cx, cy, cz = centre
        for k in ents:
            kd = self.kind(k)
            if kd == "CARTESIAN_POINT":
                c = self.coords(k)
                if c and len(c) == 3:
                    nz = set_z if set_z is not None else cz + scale * (c[2] - cz) + translate[2]
                    self.set_coords(k, [cx + scale * (c[0] - cx) + translate[0], cy + scale * (c[1] - cy) + translate[1], nz])
                elif c and len(c) == 2:
                    self.set_coords(k, [scale * c[0], scale * c[1]])
            elif kd == "VECTOR" and scale != 1.0:
                m = re.match(r"(VECTOR\s*\(\s*'(?:[^']|'')*'\s*,\s*#\d+\s*,\s*)([^)]+)(\)\s*)$", self.body[k], re.S)
                if m:
                    self.body[k] = m.group(1) + fmt(float(m.group(2)) * scale) + m.group(3)
        return self

    def remove_product(self, name, pids=None):
        """Delete a product (an old laser mark) and everything only it uses: its assembly occurrence,
        placement, representation, geometry and styling. References to it inside lists are dropped."""
        if not getattr(self, "_flat", False):
            self.flatten()
        pids = list(pids) if pids else [p for p, nm in self.products() if nm == name]
        if not pids:
            return self
        self._orig_refd = set(x for k in self.order for x in self.refs(k))
        own = set(self.product_entities(name, pids) or set())
        dead = set(pids)
        for p in pids:
            for r in self.product_reps(p):
                dead.add(r)
        dead |= own
        def top_args(body):
            """Top-level argument strings of SIMPLE(...)."""
            i = body.index("(") + 1; depth = 0; inq = False; cur = ""; args = []
            for c in body[i:]:
                if inq:
                    cur += c
                    if c == "'": inq = False
                    continue
                if c == "'": inq = True; cur += c; continue
                if c == "(":
                    depth += 1
                elif c == ")":
                    if depth == 0:
                        args.append(cur); break
                    depth -= 1
                if c == "," and depth == 0:
                    args.append(cur); cur = ""; continue
                cur += c
            return args
        changed = True
        while changed:
            changed = False
            for k in list(self.order):
                if k in dead:
                    continue
                refs = set(self.refs(k))
                if not refs & dead:
                    continue
                b = self.body[k]
                if self.kind(k) == "":
                    dead.add(k); changed = True; continue
                args = top_args(b)
                direct = [a.strip() for a in args if a.strip().startswith("#")]
                if any(int(a[1:]) in dead for a in direct if a[1:].isdigit()):
                    dead.add(k); changed = True; continue
                # drop dead refs from lists
                nb = b
                for d in refs & dead:
                    nb = re.sub(rf"\s*,\s*#{d}(?!\d)|#{d}(?!\d)\s*,\s*|#{d}(?!\d)", "", nb, count=0)
                if re.search(r"\(\s*\)", nb) and self.kind(k) in ("PRODUCT_RELATED_PRODUCT_CATEGORY", "MECHANICAL_DESIGN_GEOMETRIC_PRESENTATION_REPRESENTATION", "PRESENTATION_LAYER_ASSIGNMENT", "DRAUGHTING_MODEL"):
                    if self.kind(k) != "MECHANICAL_DESIGN_GEOMETRIC_PRESENTATION_REPRESENTATION":
                        dead.add(k); changed = True; continue
                self.body[k] = nb
        # garbage collection: keep what the file's original roots (entities nobody referenced) still reach
        referenced = set(x for k in self.order for x in self.refs(k)) if not hasattr(self, "_orig_refd") else self._orig_refd
        roots = [k for k in self.order if k not in dead and k not in referenced]
        self._orig_refd = referenced
        live = self.reach(roots, stop=dead)
        self.order = [k for k in self.order if k in live]
        for k in list(self.body):
            if k not in live:
                self.body.pop(k, None)
        self._rev = None
        return self

    # ---- proxy etch support ------------------------------------------------------------------
    def scale_axes(self, x0, y0, z0, sx, sy, sz):
        """Map a unit-box template onto [x0, x0+sx] x [y0, y0+sy] x [z0, z0+sz] (template has no pcurves)."""
        fents = self._frame_ents(self.rep_frames())
        for k in self.order:
            if self.kind(k) == "CARTESIAN_POINT" and k not in fents:
                c = self.coords(k)
                if c and len(c) == 3:
                    self.set_coords(k, [x0 + sx * c[0], y0 + sy * c[1], z0 + sz * c[2]])
        return self

    def _pd_of(self, pid):
        for pdf in self._up(pid, "PRODUCT_DEFINITION_FORMATION") + self._up(pid, "PRODUCT_DEFINITION_FORMATION_WITH_SPECIFIED_SOURCE"):
            for pd in self._up(pdf, "PRODUCT_DEFINITION"):
                return pd
        return None

    def _root_products(self):
        related = set()
        for k in self.order:
            if self.kind(k) == "NEXT_ASSEMBLY_USAGE_OCCURRENCE":
                related.add(self.refs(k)[1])
        return [(p, n) for p, n in self.products() if self._pd_of(p) not in related]

    @staticmethod
    def _remap(body, off):
        out, i, n, inq = [], 0, len(body), False
        while i < n:
            c = body[i]
            if inq:
                out.append(c)
                if c == "'":
                    if i + 1 < n and body[i + 1] == "'":
                        out.append("'"); i += 2; continue
                    inq = False
                i += 1; continue
            if c == "'":
                inq = True; out.append(c); i += 1; continue
            if c == "#":
                j = i + 1
                while j < n and body[j].isdigit():
                    j += 1
                out.append("#" + str(int(body[i + 1:j]) + off)); i = j; continue
            out.append(c); i += 1
        return "".join(out)

    def graft(self, donor, under="Adom.Chip"):
        """Replace the single child of product `under` with the donor file's whole model (its root
        product and everything below it, colours included). Both files must be flattened."""
        chip = [p for p, n in self.products() if n == under]
        if not chip:
            raise SystemExit(f"step_text: no product {under!r} to graft under")
        chip_pd = self._pd_of(chip[0])
        nauo = [k for k in self.order if self.kind(k) == "NEXT_ASSEMBLY_USAGE_OCCURRENCE" and self.refs(k)[0] == chip_pd]
        if len(nauo) != 1:
            raise SystemExit(f"step_text: {under} has {len(nauo)} children; expected the proxy only")
        nauo = nauo[0]
        old_pd = self.refs(nauo)[1]
        pds = [k for k in self.rev().get(nauo, []) if self.kind(k) == "PRODUCT_DEFINITION_SHAPE"][0]
        cdsr = [k for k in self.rev().get(pds, []) if self.kind(k) == "CONTEXT_DEPENDENT_SHAPE_REPRESENTATION"][0]
        rr = self.refs(cdsr)[0]
        m = re.search(r"REPRESENTATION_RELATIONSHIP\s*\(\s*'(?:[^']|'')*'\s*,\s*'(?:[^']|'')*'\s*,\s*#(\d+)", self.body[rr])
        old_rep = int(m.group(1))
        idt = int(re.search(r"REPRESENTATION_RELATIONSHIP_WITH_TRANSFORMATION\s*\(\s*#(\d+)", self.body[rr]).group(1))
        old_frame = self.refs(idt)[0]
        old_pid = [p for p, n in self.products() if self._pd_of(p) == old_pd][0]
        # donor root
        roots = donor._root_products()
        if len(roots) != 1:
            raise SystemExit(f"step_text: the model has {len(roots)} root products; expected one")
        r_pid, r_name = roots[0]
        r_pd = donor._pd_of(r_pid)
        r_pds = [k for k in donor.rev().get(r_pd, []) if donor.kind(k) == "PRODUCT_DEFINITION_SHAPE"][0]
        r_rep = [donor.refs(k)[-1] for k in donor.rev().get(r_pds, []) if donor.kind(k) == "SHAPE_DEFINITION_REPRESENTATION"][0]
        fr = [x for x in donor._rep_items(r_rep) if donor.kind(x) == "AXIS2_PLACEMENT_3D"]
        if fr:
            r_frame = fr[0]
        else:                                   # give the root representation a frame to be placed by
            pk = donor._add("CARTESIAN_POINT('',(0.,0.,0.))"); dz = donor._add("DIRECTION('',(0.,0.,1.))"); dx = donor._add("DIRECTION('',(1.,0.,0.))")
            r_frame = donor._add(f"AXIS2_PLACEMENT_3D('',#{pk},#{dz},#{dx})")
            b = donor.body[r_rep]
            i = b.index("(", b.index("(") + 1)
            donor.body[r_rep] = b[: i + 1] + f"#{r_frame}," + b[i + 1:]
        off = max(self.order) + 10
        for k in donor.order:
            nk = k + off
            self.body[nk] = self._remap(donor.body[k], off); self.lead[nk] = ""; self.order.append(nk)
        self._rev = None
        rep = lambda b, a, z: re.sub(rf"#{a}(?!\d)", f"#{z}", b, count=1)
        self.body[nauo] = rep(self.body[nauo], old_pd, r_pd + off)
        self.body[rr] = rep(self.body[rr], old_rep, r_rep + off)
        self.body[idt] = rep(self.body[idt], old_frame, r_frame + off)
        self._rev = None
        self.remove_product(None, [old_pid])
        # the root keeps the model's name
        for p, n in self._root_products():
            if n == "adom_mark_proxy":
                self.body[p] = self.body[p].replace("'adom_mark_proxy'", "'" + r_name.replace("'", "''") + "'")
        self._rev = None
        return self

    def rename_product(self, old, new):
        for k in self.order:
            if self.kind(k) == "PRODUCT":
                self.body[k] = self.body[k].replace(f"'{old}'", f"'{new}'")
        return self

    @property
    def text(self):
        parts = [self.head, "\n"]
        for k in self.order:
            parts.append(f"#{k} = {self.body[k]};\n")
        parts.append(self.tail)
        return "".join(parts)

    def write(self, path):
        open(path, "w", encoding="latin-1").write(self.text)
        return path


def bbox(points):
    xs, ys, zs = zip(*points)
    return [min(xs), min(ys), min(zs), max(xs), max(ys), max(zs)]


if __name__ == "__main__":
    a = sys.argv[1:]
    if a and a[0] == "rotate":
        Step(a[1]).rotate90(a[3], int(a[4])).write(a[2])
    elif a and a[0] == "products":
        s = Step(a[1])
        for k, n in s.products():
            print(k, n)
    else:
        print(__doc__); sys.exit(2)