File size: 7,525 Bytes
96d4c08 f2b4b4e 96d4c08 f2b4b4e 96d4c08 f2b4b4e 96d4c08 f2b4b4e 96d4c08 e8d38ed f2b4b4e e8d38ed f2b4b4e e8d38ed f2b4b4e e8d38ed f2b4b4e 96d4c08 f2b4b4e e8d38ed 96d4c08 e8d38ed f2b4b4e 96d4c08 f2b4b4e e8d38ed f2b4b4e e8d38ed f2b4b4e e8d38ed | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 | """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
|