| """Deterministically snap a catalog part onto a creature's bounding box. |
| |
| Pure functions, no model. The selector chooses {part, anchor, scale, color}; |
| this module computes the geometry. See the design spec for the anchor rules. |
| """ |
| from __future__ import annotations |
|
|
| from copy import deepcopy |
|
|
| from .parts import ANCHORS, PARTS |
| from .boxes import repair_boxes |
|
|
|
|
| def bbox(boxes: list[dict]) -> tuple[int, int, int, int, int, int]: |
| """(minx, maxx, miny, maxy, minz, maxz). x,z = center; y = bottom.""" |
| minx = min(b["x"] - b["w"] / 2 for b in boxes) |
| maxx = max(b["x"] + b["w"] / 2 for b in boxes) |
| miny = min(b["y"] for b in boxes) |
| maxy = max(b["y"] + b["h"] for b in boxes) |
| minz = min(b["z"] - b["d"] / 2 for b in boxes) |
| maxz = max(b["z"] + b["d"] / 2 for b in boxes) |
| return (int(minx), int(maxx), int(miny), int(maxy), int(minz), int(maxz)) |
|
|
|
|
| def _mirror_x(boxes: list[dict]) -> list[dict]: |
| return [{**b, "x": -b["x"]} for b in boxes] |
|
|
|
|
| def _scaled(boxes: list[dict], scale: float) -> list[dict]: |
| """Scale by a float multiplier, rounding to the integer voxel grid. The cube |
| size stays 1 — a bigger part is simply more voxels (wider/taller block), per |
| the design: "voxel stays the same size, the whole block just gets bigger".""" |
| if abs(float(scale) - 1.0) < 1e-9: |
| return deepcopy(boxes) |
| s = float(scale) |
| out = [] |
| for b in boxes: |
| nb = dict(b) |
| for k in ("x", "y", "z"): |
| nb[k] = round(b[k] * s) |
| for k in ("w", "h", "d"): |
| nb[k] = max(1, round(b[k] * s)) |
| out.append(nb) |
| return out |
|
|
|
|
| def _cells(boxes: list[dict]) -> list[tuple]: |
| """Explode boxes into (x, y, z, color) unit cells, matching voxelsFromBoxes |
| (x,z = center, y = bottom).""" |
| out = [] |
| for b in boxes: |
| cx, cy, cz = b["x"], b["y"], b["z"] |
| w, h, d = b["w"], b["h"], b["d"] |
| color = b.get("color", "#caa06f") |
| for ix in range(w): |
| for iy in range(h): |
| for iz in range(d): |
| out.append((round(cx + ix - (w - 1) / 2), cy + iy, |
| round(cz + iz - (d - 1) / 2), color)) |
| return out |
|
|
|
|
| def _rotate(boxes: list[dict], axis: str, quarters: int) -> list[dict]: |
| """Rotate the part by 90° increments about its own center, on the integer |
| grid (exact, per-voxel). Returns 1x1x1 boxes — small parts, renders identically |
| through voxelsFromBoxes.""" |
| q = quarters % 4 |
| if q == 0: |
| return deepcopy(boxes) |
| cells = _cells(boxes) |
| if not cells: |
| return deepcopy(boxes) |
| cx = sum(c[0] for c in cells) / len(cells) |
| cy = sum(c[1] for c in cells) / len(cells) |
| cz = sum(c[2] for c in cells) / len(cells) |
| out = [] |
| for x, y, z, color in cells: |
| dx, dy, dz = x - cx, y - cy, z - cz |
| for _ in range(q): |
| if axis == "x": |
| dy, dz = -dz, dy |
| elif axis == "z": |
| dx, dy = -dy, dx |
| else: |
| dx, dz = -dz, dx |
| out.append({"x": round(cx + dx), "y": round(cy + dy), |
| "z": round(cz + dz), "w": 1, "h": 1, "d": 1, "color": color}) |
| return out |
|
|
|
|
| def _translate(boxes: list[dict], dx: int, dy: int, dz: int) -> list[dict]: |
| return [{**b, "x": b["x"] + dx, "y": b["y"] + dy, "z": b["z"] + dz} for b in boxes] |
|
|
|
|
| def _offset_for_anchor(anchor: str, tgt, part) -> tuple[int, int, int]: |
| tminx, tmaxx, tminy, tmaxy, tminz, tmaxz = tgt |
| pminx, pmaxx, pminy, pmaxy, pminz, pmaxz = part |
| tcx = (tminx + tmaxx) // 2 |
| tcz = (tminz + tmaxz) // 2 |
| tcy = (tminy + tmaxy) // 2 |
| if anchor == "bottom": |
| return (tcx, tminy - pmaxy, tcz) |
| if anchor == "rear": |
| return (tcx, -pminy, tminz - pmaxz) |
| if anchor == "front": |
| return (tcx, -pminy, tmaxz - pminz) |
| if anchor == "left": |
| return (tminx - pmaxx, tcy - (pminy + pmaxy) // 2, tcz) |
| if anchor == "right": |
| return (tmaxx - pminx, tcy - (pminy + pmaxy) // 2, tcz) |
| return (tcx, tmaxy - pminy, tcz) |
|
|
|
|
| def _mirror_about_x(boxes: list[dict], cx: float) -> list[dict]: |
| """Mirror placed boxes across the body's x-center (so a symmetric part STRADDLES |
| the body — left half hugging the left face, its mirror hugging the right).""" |
| return [{**b, "x": int(round(2 * cx - b["x"]))} for b in boxes] |
|
|
|
|
| def _even(n: int, extent: int) -> list[int]: |
| """n offsets spread evenly across, and centered within, `extent` — so multiple |
| instances sit ON the body face (within its footprint), not drifting off it.""" |
| if n <= 1: |
| return [0] |
| return [int(round(((i + 0.5) / n - 0.5) * extent)) for i in range(n)] |
|
|
|
|
| def _slot_offsets(anchor: str, count: int, tgt) -> list[tuple]: |
| """Tangent (dx,dy,dz) shifts for `count` instances, spread evenly across the |
| anchor face and clamped to the body's extent so they hug it. Side + top/bottom |
| anchors fan front-back (z); front/rear anchors fan left-right (x).""" |
| if count <= 1: |
| return [(0, 0, 0)] |
| tminx, tmaxx, tminy, tmaxy, tminz, tmaxz = tgt |
| if anchor in ("front", "rear"): |
| return [(o, 0, 0) for o in _even(count, max(1, tmaxx - tminx))] |
| return [(0, 0, o) for o in _even(count, max(1, tmaxz - tminz))] |
|
|
|
|
| def assemble_part(boxes: list[dict], part_id: str, anchor: str, |
| scale: float = 1, color: str | None = None, |
| count: int = 1, rotation: tuple | None = None) -> list[dict]: |
| """Return boxes + the placed part(s). Unknown part_id (or empty boxes) → unchanged. |
| |
| Parts FUSE rather than stack: each placed box is tagged `_part`/`_pc`, so adding |
| the same part again accumulates its instance count and RE-distributes the whole |
| set evenly across the anchor face (hugging the body), instead of piling new |
| copies on the previous ones. Symmetric parts straddle the body (left half on the |
| left face, mirrored across the body center to the right). scale is a float |
| (rounded to grid); rotation is (axis, quarter_turns) for an exact 90° turn.""" |
| part = PARTS.get(part_id) |
| if part is None or not boxes: |
| return boxes |
| if anchor not in ANCHORS: |
| anchor = part["default_anchor"] |
|
|
| |
| |
| existing = [b for b in boxes if b.get("_part") == part_id] |
| base = [b for b in boxes if b.get("_part") != part_id] or list(boxes) |
| prev = int(existing[0].get("_pc", 0)) if existing else 0 |
| total = max(1, prev + max(1, int(count))) |
|
|
| |
| |
| local = _scaled(part["boxes"], scale or part.get("default_scale", 1)) |
| if rotation: |
| local = _rotate(local, rotation[0], int(rotation[1])) |
| if color: |
| local = [{**b, "color": color} for b in local] |
|
|
| tgt = bbox(base) |
| tcx = (tgt[0] + tgt[1]) / 2 |
| local_bb = bbox(local) |
| dx, dy, dz = _offset_for_anchor(anchor, tgt, local_bb) |
| placed: list[dict] = [] |
| for sx, sy, sz in _slot_offsets(anchor, total, tgt): |
| inst = _translate(local, dx + sx, dy + sy, dz + sz) |
| if part.get("symmetric"): |
| inst = inst + _mirror_about_x(inst, tcx) |
| placed += inst |
| placed = repair_boxes(placed) |
| placed = [{**b, "_part": part_id, "_pc": total} for b in placed] |
| return base + placed |
|
|