← 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
| Field | Type | Description |
|---|---|---|
| spec | object | shape (box, bottle, badge, sign), label, palette, as a model wrote them from the brief. |
| shape | string | Not declared |
| label | string | Not declared |
| palette | object | bg, accent, text as hex colours; a colour that is not a hex is replaced from a safe pair. |
| name_prefix | string | Not declared |
Outputs
| Field | Type | Description |
|---|---|---|
| filesrequired | array | The files made, each with its bytes as base64. |
| countrequired | integer | Not declared |
| shape | string | Not declared |
| palette | object | bg, accent, text as hex colours; a colour that is not a hex is replaced from a safe pair. |
| triangles | integer | Not 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("&", "&").replace("<", "<").replace(">", ">").replace('"', """))
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.