← All ivy-nodes
IVYXSTUDIO · IVY NODE
D

Design: a 3D mock-up as glTF

ivy.node.design-glb · v0.1.0

ivyx✓

Builds a product mock-up (a box, a bottle, a badge or a sign) as a glTF 2.0 binary in the brand's colours, with smooth normals and PBR materials, and draws an isometric poster of the same thing as SVG for a thumbnail. Geometry from code, no model, no network.

#design#3d#gltf#mockup#agency

Inputs

FieldTypeDescription
specobjectshape (box, bottle, badge, sign), label, palette, as a model wrote them from the brief.
shapestringNot declared
labelstringNot declared
paletteobjectbg, accent, text as hex colours; a colour that is not a hex is replaced from a safe pair.
name_prefixstringNot declared

Outputs

FieldTypeDescription
filesrequiredarrayThe files made, each with its bytes as base64.
countrequiredintegerNot declared
shapestringNot declared
paletteobjectbg, accent, text as hex colours; a colour that is not a hex is replaced from a safe pair.
trianglesintegerNot declared

Source

python

# Input preparation
inp = __ivy_ctx__["nodes"][__ivy_node_id__]["input"]

# Compute
# Shared by every design node (built into each by build.mjs): colours, text, files.
import base64, re, math

SAFE_PALETTES = [
    {"bg": "#1E1B4B", "accent": "#F6A23B", "text": "#F8F7FF"},
    {"bg": "#F4F2FF", "accent": "#6C3BFF", "text": "#1E1B4B"},
    {"bg": "#0F3D3E", "accent": "#E8C547", "text": "#F3F7F6"},
    {"bg": "#FFF4E6", "accent": "#D9480F", "text": "#2B1D12"},
    {"bg": "#101418", "accent": "#3DDC97", "text": "#F2F4F6"},
]

def hex_ok(s):
    return isinstance(s, str) and re.fullmatch(r"#[0-9a-fA-F]{6}", s.strip()) is not None

def palette_of(p, seed=0):
    """A palette with three valid hex colours; what the model got wrong is replaced from a safe pair."""
    base = SAFE_PALETTES[abs(int(seed)) % len(SAFE_PALETTES)]
    out = {}
    p = p if isinstance(p, dict) else {}
    for k in ("bg", "accent", "text"):
        v = p.get(k)
        out[k] = v.strip().upper() if hex_ok(v) else base[k]
    if out["bg"].upper() == out["text"].upper():
        out["text"] = base["text"] if base["text"].upper() != out["bg"].upper() else base["bg"]
    return out

def rgb(h):
    h = h.lstrip("#")
    return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))

def mix(a, b, t):
    return tuple(int(round(a[i] + (b[i] - a[i]) * t)) for i in range(3))

def to_hex(c):
    return "#%02X%02X%02X" % c

def lum(h):
    r, g, b = [x / 255 for x in rgb(h)]
    return 0.2126 * r + 0.7152 * g + 0.0722 * b

def esc(s):
    return (str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;"))

def text_of(v, limit, default=""):
    s = " ".join(str(v).split()) if isinstance(v, (str, int, float)) else ""
    if not s:
        s = default
    return s[:limit].rstrip() if len(s) > limit else s

def wrap(s, max_chars):
    words = s.split()
    lines, cur = [], ""
    for w in words:
        if cur and len(cur) + 1 + len(w) > max_chars:
            lines.append(cur)
            cur = w
        else:
            cur = (cur + " " + w).strip()
    if cur:
        lines.append(cur)
    return lines or [""]

def b64(data):
    return base64.b64encode(data).decode("ascii")

def file_entry(name, mime, data, kind, width=None, height=None, caption=None, variant=None, extra=None):
    f = {"name": name, "mime": mime, "kind": kind, "bytes": len(data), "data": b64(data)}
    if width:
        f["width"] = int(width)
    if height:
        f["height"] = int(height)
    if caption:
        f["caption"] = caption
    if variant:
        f["variant"] = variant
    if extra:
        f.update(extra)
    return f

def slug(s):
    s = re.sub(r"[^a-z0-9]+", "-", str(s).lower()).strip("-")
    return s[:40] or "design"

def font():
    return "Inter, -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif"

# design-glb: a product mock-up (box, bottle, badge, sign) as a glTF 2.0 binary in the brand's colours, and an isometric poster SVG of the same thing for a thumbnail.
import struct, json, math


class DesignGlb:
    "Design: a 3D mock-up as glTF"

    def run(self, inp):
        out = {}
        spec = inp.get("spec") if isinstance(inp.get("spec"), dict) else {}
        shape = (spec.get("shape") or inp.get("shape") or "box")
        shape = shape if shape in ("box", "bottle", "badge", "sign") else "box"
        label = text_of(spec.get("label") or inp.get("label"), 24, "Brand")
        pal = palette_of(spec.get("palette") or inp.get("palette"), sum(ord(c) for c in label))
        prefix = slug(inp.get("name_prefix") or label)
        FF = font()

        # ---------- geometry: positions, normals, indices in local space ----------
        def sphere(r=1.0, seg=24, rings=16):
            P, N, I = [], [], []
            for y in range(rings + 1):
                phi = y / rings * math.pi
                for x in range(seg + 1):
                    th = x / seg * math.pi * 2
                    n = (math.cos(th) * math.sin(phi), math.cos(phi), math.sin(th) * math.sin(phi))
                    P.append((n[0] * r, n[1] * r, n[2] * r)); N.append(n)
            for y in range(rings):
                for x in range(seg):
                    a = y * (seg + 1) + x; b = a + seg + 1
                    if y != 0: I += [a, a + 1, b]
                    if y != rings - 1: I += [a + 1, b + 1, b]
            return P, N, I

        def rounded_box(w, h, d, r, seg=20, rings=14):
            P, N, I = sphere(1.0, seg, rings)
            ex, ey, ez = max(w / 2 - r, 0), max(h / 2 - r, 0), max(d / 2 - r, 0)
            sgn = lambda v: (v > 0) - (v < 0)
            P = [(sgn(n[0]) * ex + n[0] * r, sgn(n[1]) * ey + n[1] * r, sgn(n[2]) * ez + n[2] * r) for n in N]
            return P, N, I

        def cylinder(r, h, seg=32):
            P, N, I = [], [], []
            hh = h / 2
            for x in range(seg + 1):
                th = x / seg * math.pi * 2; c, s = math.cos(th), math.sin(th)
                P += [(c * r, hh, s * r), (c * r, -hh, s * r)]; N += [(c, 0, s), (c, 0, s)]
            for x in range(seg):
                a = x * 2; I += [a, a + 2, a + 1, a + 1, a + 2, a + 3]
            for y, ny in ((hh, 1), (-hh, -1)):
                c0 = len(P); P.append((0, y, 0)); N.append((0, ny, 0))
                for x in range(seg + 1):
                    th = x / seg * math.pi * 2; P.append((math.cos(th) * r, y, math.sin(th) * r)); N.append((0, ny, 0))
                for x in range(seg):
                    I += [c0, c0 + 2 + x, c0 + 1 + x] if ny > 0 else [c0, c0 + 1 + x, c0 + 2 + x]
            return P, N, I

        def prism(r, h, sides=6):
            return cylinder(r, h, sides)

        def euler(dx=0, dy=0, dz=0):
            x, y, z = [math.radians(v) / 2 for v in (dx, dy, dz)]
            cx, sx, cy, sy, cz, sz = math.cos(x), math.sin(x), math.cos(y), math.sin(y), math.cos(z), math.sin(z)
            return (sx * cy * cz + cx * sy * sz, cx * sy * cz - sx * cy * sz, cx * cy * sz + sx * sy * cz, cx * cy * cz - sx * sy * sz)

        def rotq(v, q):
            x, y, z, w = q; vx, vy, vz = v
            ix = w * vx + y * vz - z * vy; iy = w * vy + z * vx - x * vz; iz = w * vz + x * vy - y * vx; iw = -x * vx - y * vy - z * vz
            return (ix * w + iw * -x + iy * -z - iz * -y, iy * w + iw * -y + iz * -x - ix * -z, iz * w + iw * -z + ix * -y - iy * -x)

        def norm(v):
            l = math.sqrt(v[0] ** 2 + v[1] ** 2 + v[2] ** 2) or 1
            return (v[0] / l, v[1] / l, v[2] / l)

        def xf(g, s=(1, 1, 1), q=(0, 0, 0, 1), t=(0, 0, 0)):
            P, N, I = g
            P2 = [rotq((p[0] * s[0], p[1] * s[1], p[2] * s[2]), q) for p in P]
            P2 = [(p[0] + t[0], p[1] + t[1], p[2] + t[2]) for p in P2]
            N2 = [rotq(norm((n[0] / s[0], n[1] / s[1], n[2] / s[2])), q) for n in N]
            return P2, N2, I

        def lin(h):
            out = []
            for c in rgb(h):
                c = c / 255
                out.append(c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4)
            return out

        def mat(name, h, rough=0.4, metal=0.0):
            return {"name": name, "pbrMetallicRoughness": {"baseColorFactor": lin(h) + [1.0], "metallicFactor": metal, "roughnessFactor": rough}, "doubleSided": False}

        # ---------- the shapes: a list of (name, geometry, material) ----------
        bg, ac, tx = pal["bg"], pal["accent"], pal["text"]
        materials = [mat("body", bg, 0.45), mat("accent", ac, 0.35), mat("light", tx, 0.5), mat("metal", "#C8CCD4", 0.25, 0.9)]
        BODY, ACCENT, LIGHT, METAL = 0, 1, 2, 3
        parts = []
        if shape == "box":
            w, h, d = 1.0, 1.25, 0.5
            parts.append(("body", xf(rounded_box(w, h, d, 0.05), t=(0, h / 2, 0)), BODY))
            parts.append(("lid", xf(rounded_box(w * 1.02, 0.12, d * 1.02, 0.03), t=(0, h - 0.06, 0)), ACCENT))
            parts.append(("label", xf(rounded_box(w * 0.7, h * 0.42, 0.02, 0.01), t=(0, h * 0.48, d / 2 + 0.005)), LIGHT))
            parts.append(("mark", xf(cylinder(w * 0.12, 0.012, 32), q=euler(90, 0, 0), t=(0, h * 0.62, d / 2 + 0.02)), ACCENT))
        elif shape == "bottle":
            parts.append(("body", xf(cylinder(0.3, 0.95, 40), t=(0, 0.475, 0)), BODY))
            parts.append(("shoulder", xf(sphere(0.3, 40, 20), s=(1, 0.5, 1), t=(0, 0.95, 0)), BODY))
            parts.append(("neck", xf(cylinder(0.11, 0.3, 32), t=(0, 1.2, 0)), BODY))
            parts.append(("cap", xf(cylinder(0.13, 0.14, 32), t=(0, 1.4, 0)), ACCENT))
            parts.append(("band", xf(cylinder(0.305, 0.42, 40), t=(0, 0.5, 0)), LIGHT))
            parts.append(("mark", xf(cylinder(0.11, 0.02, 32), q=euler(90, 0, 0), t=(0, 0.5, 0.3)), ACCENT))
        elif shape == "badge":
            parts.append(("disc", xf(prism(0.6, 0.1, 6), q=euler(90, 0, 0), t=(0, 0.6, 0)), ACCENT))
            parts.append(("ring", xf(prism(0.5, 0.12, 6), q=euler(90, 0, 0), t=(0, 0.6, 0)), BODY))
            parts.append(("core", xf(cylinder(0.3, 0.14, 40), q=euler(90, 0, 0), t=(0, 0.6, 0)), LIGHT))
            parts.append(("pin", xf(cylinder(0.04, 0.3, 16), q=euler(0, 0, 0), t=(0, 0.15, 0)), METAL))
        else:  # sign
            parts.append(("post", xf(cylinder(0.05, 1.2, 20), t=(0, 0.6, 0)), METAL))
            parts.append(("board", xf(rounded_box(1.3, 0.7, 0.06, 0.04), t=(0, 1.25, 0)), BODY))
            parts.append(("frame", xf(rounded_box(1.36, 0.76, 0.04, 0.04), t=(0, 1.25, -0.02)), ACCENT))
            parts.append(("plate", xf(rounded_box(1.0, 0.32, 0.02, 0.02), t=(0, 1.25, 0.035)), LIGHT))

        # ---------- the glTF binary ----------
        bufs = bytearray(); views = []; accessors = []
        def pad4(n):
            return (4 - n % 4) % 4
        def view(data, target):
            views.append({"buffer": 0, "byteOffset": len(bufs), "byteLength": len(data), "target": target})
            bufs.extend(data); bufs.extend(b"\x00" * pad4(len(data)))
            return len(views) - 1
        def acc(data, count, typ, comp, target, mn=None, mx=None):
            a = {"bufferView": view(data, target), "componentType": comp, "count": count, "type": typ}
            if mn is not None:
                a["min"] = mn; a["max"] = mx
            accessors.append(a); return len(accessors) - 1
        meshes, nodes = [], []
        for name, (P, N, I), m in parts:
            pos = struct.pack("<%df" % (len(P) * 3), *[c for p in P for c in p])
            nrm = struct.pack("<%df" % (len(N) * 3), *[c for n in N for c in n])
            mn = [min(p[i] for p in P) for i in range(3)]; mx = [max(p[i] for p in P) for i in range(3)]
            big = len(P) >= 65536
            idx = struct.pack("<%d%s" % (len(I), "I" if big else "H"), *I)
            prim = {"attributes": {"POSITION": acc(pos, len(P), "VEC3", 5126, 34962, mn, mx), "NORMAL": acc(nrm, len(N), "VEC3", 5126, 34962)}, "indices": acc(idx, len(I), "SCALAR", 5125 if big else 5123, 34963), "material": m, "mode": 4}
            meshes.append({"name": name, "primitives": [prim]}); nodes.append({"name": name, "mesh": len(meshes) - 1})
        root = {"name": "%s-%s" % (prefix, shape), "children": list(range(len(nodes)))}
        nodes.append(root)
        gltf = {"asset": {"version": "2.0", "generator": "ivy.node.design-glb", "extras": {"shape": shape, "label": label, "palette": pal}}, "scene": 0, "scenes": [{"name": root["name"], "nodes": [len(nodes) - 1]}], "nodes": nodes, "meshes": meshes, "materials": materials, "accessors": accessors, "bufferViews": views, "buffers": [{"byteLength": len(bufs)}]}
        jb = json.dumps(gltf, separators=(",", ":")).encode("utf-8"); jb += b" " * pad4(len(jb))
        total = 12 + 8 + len(jb) + 8 + len(bufs)
        glb = b"glTF" + struct.pack("<II", 2, total) + struct.pack("<I", len(jb)) + b"JSON" + jb + struct.pack("<I", len(bufs)) + b"BIN\x00" + bytes(bufs)

        # ---------- the poster: an isometric drawing of the same thing ----------
        S = 640
        dark = to_hex(mix(rgb(bg), (0, 0, 0), 0.25)); mid = to_hex(mix(rgb(bg), (0, 0, 0), 0.1)); light_bg = to_hex(mix(rgb(bg), (255, 255, 255), 0.12))
        paper = to_hex(mix(rgb(bg), rgb(tx), 0.9)) if lum(bg) < 0.5 else to_hex(mix(rgb(bg), (255, 255, 255), 0.75))
        P = ['<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d" role="img" aria-label="%s, %s mock-up">' % (S, S, S, S, esc(label), shape), '<title>%s</title>' % esc(label), '<rect width="%d" height="%d" fill="%s"/>' % (S, S, paper), '<ellipse cx="%d" cy="%d" rx="%d" ry="%d" fill="#000" opacity="0.12"/>' % (S // 2, int(S * 0.8), int(S * 0.3), int(S * 0.06))]
        cx, cy = S / 2, S * 0.5
        if shape == "box":
            W, H, D = S * 0.3, S * 0.38, S * 0.15
            top = [(cx, cy - H / 2 - D / 2), (cx + W, cy - H / 2), (cx, cy - H / 2 + D / 2), (cx - W, cy - H / 2)]
            P.append('<polygon points="%s" fill="%s"/>' % (" ".join("%.0f,%.0f" % p for p in top), light_bg))
            P.append('<polygon points="%.0f,%.0f %.0f,%.0f %.0f,%.0f %.0f,%.0f" fill="%s"/>' % (cx - W, cy - H / 2, cx, cy - H / 2 + D / 2, cx, cy + H / 2 + D / 2, cx - W, cy + H / 2, mid))
            P.append('<polygon points="%.0f,%.0f %.0f,%.0f %.0f,%.0f %.0f,%.0f" fill="%s"/>' % (cx, cy - H / 2 + D / 2, cx + W, cy - H / 2, cx + W, cy + H / 2, cx, cy + H / 2 + D / 2, dark))
            P.append('<polygon points="%s" fill="%s"/>' % (" ".join("%.0f,%.0f" % (p[0], p[1] + 10) for p in top), ac))
            P.append('<polygon points="%.0f,%.0f %.0f,%.0f %.0f,%.0f %.0f,%.0f" fill="%s"/>' % (cx + W * 0.15, cy - H * 0.1 + D * 0.5, cx + W * 0.85, cy - H * 0.2, cx + W * 0.85, cy + H * 0.25, cx + W * 0.15, cy + H * 0.35 + D * 0.5, tx))
            lf = int(min(S * 0.045, (W * 0.62) / max(len(label), 1) / 0.58))
            P.append('<text x="%.0f" y="%.0f" text-anchor="middle" font-family="%s" font-size="%d" font-weight="700" fill="%s" transform="skewY(-26) translate(0 %.0f)">%s</text>' % (cx + W * 0.5, cy + H * 0.1 + D * 0.25, FF, lf, bg, (cx + W * 0.5) * 0.488, esc(label)))
        elif shape == "bottle":
            bw, bh = S * 0.22, S * 0.5
            P.append('<rect x="%.0f" y="%.0f" width="%.0f" height="%.0f" rx="%.0f" fill="%s"/>' % (cx - bw / 2, cy - bh * 0.3, bw, bh, bw * 0.18, bg))
            P.append('<rect x="%.0f" y="%.0f" width="%.0f" height="%.0f" rx="%.0f" fill="%s"/>' % (cx - bw * 0.18, cy - bh * 0.52, bw * 0.36, bh * 0.25, bw * 0.05, bg))
            P.append('<rect x="%.0f" y="%.0f" width="%.0f" height="%.0f" rx="%.0f" fill="%s"/>' % (cx - bw * 0.22, cy - bh * 0.6, bw * 0.44, bh * 0.12, bw * 0.05, ac))
            P.append('<rect x="%.0f" y="%.0f" width="%.0f" height="%.0f" fill="%s"/>' % (cx - bw / 2, cy - bh * 0.05, bw, bh * 0.4, tx))
            P.append('<circle cx="%.0f" cy="%.0f" r="%.0f" fill="%s"/>' % (cx, cy + bh * 0.08, bw * 0.16, ac))
            P.append('<text x="%.0f" y="%.0f" text-anchor="middle" font-family="%s" font-size="%d" font-weight="700" fill="%s">%s</text>' % (cx, cy + bh * 0.3, FF, int(S * 0.034), bg, esc(label)))
            P.append('<rect x="%.0f" y="%.0f" width="%.0f" height="%.0f" rx="6" fill="#fff" opacity="0.18"/>' % (cx - bw * 0.42, cy - bh * 0.25, bw * 0.1, bh * 0.85))
        elif shape == "badge":
            r = S * 0.3
            pts = " ".join("%.0f,%.0f" % (cx + r * math.cos(math.radians(60 * i - 30)), cy + r * math.sin(math.radians(60 * i - 30))) for i in range(6))
            pts2 = " ".join("%.0f,%.0f" % (cx + r * 0.82 * math.cos(math.radians(60 * i - 30)), cy + r * 0.82 * math.sin(math.radians(60 * i - 30))) for i in range(6))
            P.append('<polygon points="%s" fill="%s"/>' % (pts, ac)); P.append('<polygon points="%s" fill="%s"/>' % (pts2, bg))
            P.append('<circle cx="%.0f" cy="%.0f" r="%.0f" fill="%s"/>' % (cx, cy, r * 0.5, tx))
            P.append('<text x="%.0f" y="%.0f" text-anchor="middle" font-family="%s" font-size="%d" font-weight="800" fill="%s">%s</text>' % (cx, cy + S * 0.02, FF, int(S * 0.06), bg, esc("".join(w[0] for w in label.split()[:2]).upper())))
            P.append('<text x="%.0f" y="%.0f" text-anchor="middle" font-family="%s" font-size="%d" font-weight="600" fill="%s">%s</text>' % (cx, cy + r * 1.35, FF, int(S * 0.04), to_hex(mix(rgb(bg), (0, 0, 0), 0.2)) if lum(paper) > 0.5 else tx, esc(label)))
        else:
            bw, bh = S * 0.62, S * 0.3
            P.append('<rect x="%.0f" y="%.0f" width="%.0f" height="%.0f" rx="8" fill="#9AA0A8"/>' % (cx - S * 0.02, cy, S * 0.04, S * 0.33))
            P.append('<rect x="%.0f" y="%.0f" width="%.0f" height="%.0f" rx="14" fill="%s"/>' % (cx - bw / 2 - 8, cy - bh - 8 - S * 0.02, bw + 16, bh + 16, ac))
            P.append('<rect x="%.0f" y="%.0f" width="%.0f" height="%.0f" rx="10" fill="%s"/>' % (cx - bw / 2, cy - bh - S * 0.02, bw, bh, bg))
            P.append('<rect x="%.0f" y="%.0f" width="%.0f" height="%.0f" rx="8" fill="%s"/>' % (cx - bw * 0.4, cy - bh * 0.72 - S * 0.02, bw * 0.8, bh * 0.44, tx))
            P.append('<text x="%.0f" y="%.0f" text-anchor="middle" font-family="%s" font-size="%d" font-weight="800" fill="%s">%s</text>' % (cx, cy - bh * 0.42 - S * 0.02, FF, int(S * 0.055), bg, esc(label)))
        P.append('<text x="%d" y="%d" font-family="%s" font-size="%d" fill="%s" opacity="0.6">%s · 3D mock-up</text>' % (int(S * 0.05), int(S * 0.95), FF, int(S * 0.028), to_hex(mix(rgb(bg), (0, 0, 0), 0.4)) if lum(paper) > 0.5 else tx, shape))
        P.append("</svg>")
        poster = "\n".join(P).encode("utf-8")

        glb_name = "%s-%s.glb" % (prefix, shape)
        files = [
            file_entry(glb_name, "model/gltf-binary", glb, "model3d", None, None, "%s as a %s, in the brand's colours." % (label, shape), shape, {"poster": "%s-%s-poster.svg" % (prefix, shape)}),
            file_entry("%s-%s-poster.svg" % (prefix, shape), "image/svg+xml", poster, "vector", S, S, "Poster of the %s mock-up." % shape, "poster", {"preview_for": glb_name}),
        ]
        out = {"files": files, "count": 2, "shape": shape, "palette": pal, "triangles": sum(len(g[2]) // 3 for _, g, _ in parts)}
        return out


result = DesignGlb().run(inp)

# Output collection (the runner reads it here)
out = __ivy_ctx__["nodes"][__ivy_node_id__]["output"]
out.update(result)

Tests

Requires: python:3.9

  • box

    A box with a label is a glTF binary and its isometric poster, the poster naming the model it previews.

  • bottle

    A bottle is its own shape.

  • unknown-shape

    A shape the node does not know is a box.