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