"""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: # "y" 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) # "top" (and default) 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"] # Accumulate: peel off any earlier instances of THIS part; the rest is the body # (and other parts) we anchor against and keep. 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))) # The authored part is the LEFT half for symmetric parts; place that, then mirror # across the body center so it straddles (never pre-mirror into one blob). 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