Skip to main content

A Business-Card Stand for the Neighbours

·8370 words·40 mins
A new store, Mythos, opened up in the neighbourhood — wanted to make them something nice for the counter. Also, a good excuse to practice two techniques I hadn’t done before: stamping a logo in from a raster image, and a genuinely zero-support resin print.

What it is
#

A two-part stand for the counter: a holder with three upright slots for stacks of ~50 business cards each (65×65mm cards), and a backplate carrying the Mythos logo and wordmark in relief. Sized for the Anycubic Photon Mono 2’s build volume, printed in resin.


Technique 1: stamping a logo in from a photo
#

The source was just a small JPG of the Mythos logo. Getting from that to a clean printable relief took two passes:

First attempt — heightfield. Threshold the image to pure black/white, blur it smooth, then displace the backplate’s front face per-pixel from that mask as a continuous heightfield. Technically worked, but under raking light the fine linework (letter serifs, thin mountain lines) still read as fuzzy, slightly stair-stepped shading — the source JPG is only 183×127px, so there was never much real detail to work with, and upscaling can’t add it back.

Second attempt — vector contours. Instead of a per-pixel heightmap, run marching squares over the same thresholded mask to get clean closed polygons, then extrude those as a flat plateau with sharp 90° walls. This needed handling letters with counters correctly — the hole inside an “O” has to be tagged as a hole, not read as its own separate shape.

Flat-plateau-with-sharp-edges turned out to be the better call anyway, independent of the resolution problem: a crisp edge is much easier to hand-paint up to than a soft gradient.

preprocess_logo.py
"""
One-off preprocessing (system python3 + PIL/numpy, NOT run inside Blender):
grayscale the source Mythos logo JPG, threshold it to pure black/white
(Otsu) to strip JPEG grey-halo noise around the linework, blur the clean
binary result, then upscale with a smoothing resize. Output feeds
card_stand's image-stamp version as a heightmap.

The blur step matters: the source JPG is only 183x127px, so fine detail
(letter serifs, thin mountain lines) is only 1-2 native pixels wide.
LANCZOS upscaling smooths the RESIZE but can't add real resolution, so a
plain threshold+upscale still reads as small stairsteps once it's a 3D
relief under raking light - showing up as fuzzy/pixelated shadows right
where the detail is finest. Since this gets hand-painted afterward
anyway, a real blur (trading a little fidelity for genuinely smooth
edges) is the right call over chasing more source resolution.
"""

import numpy as np
from PIL import Image, ImageFilter

SRC = "/Users/mannil/Downloads/mythos_liten logga_v3.jpg"
OUT = "/Users/mannil/Desktop/studio-m/TSONS/card_stand/logo_mask.png"
UPSCALE_W = 720  # keeps the ~183:127 aspect ratio
BLUR_RADIUS = 0.5  # at native (183px-wide) resolution, before upscaling


def otsu_threshold(gray):
    hist, _ = np.histogram(gray, bins=256, range=(0, 256))
    total = gray.size
    sum_all = np.dot(np.arange(256), hist)
    sum_bg, weight_bg, best_var, best_t = 0.0, 0, -1.0, 128
    for t in range(256):
        weight_bg += hist[t]
        if weight_bg == 0:
            continue
        weight_fg = total - weight_bg
        if weight_fg == 0:
            break
        sum_bg += t * hist[t]
        mean_bg = sum_bg / weight_bg
        mean_fg = (sum_all - sum_bg) / weight_fg
        var_between = weight_bg * weight_fg * (mean_bg - mean_fg) ** 2
        if var_between > best_var:
            best_var, best_t = var_between, t
    return best_t


def main():
    im = Image.open(SRC).convert("L")
    gray = np.array(im)

    t = otsu_threshold(gray)
    print(f"Otsu threshold: {t}")
    binary = np.where(gray < t, 0, 255).astype(np.uint8)  # ink=0 (dark), bg=255

    bw = Image.fromarray(binary, mode="L")
    bw_blurred = bw.filter(ImageFilter.GaussianBlur(radius=BLUR_RADIUS))
    h = int(UPSCALE_W * bw.height / bw.width)
    bw_smooth = bw_blurred.resize((UPSCALE_W, h), Image.LANCZOS)
    bw_smooth.save(OUT)
    print(f"Saved {OUT} ({bw_smooth.size[0]}x{bw_smooth.size[1]})")


if __name__ == "__main__":
    main()
extract_logo_contours.py
"""
Vector-contour extraction (system python3 + skimage, NOT Blender) for the
flat/sharp-edge stamp: marching squares on the (lightly blurred) binary
mask gives clean closed polygons directly, instead of a per-pixel
heightmap - the right tool now that the design is "flat plateau, sharp
90-degree walls" rather than a continuous relief. Output feeds
card_stand_v4's build_logo_stamp.

Handles letters with counters (the "O" in MYTHOS) - each contour is
tagged as an outer shape or a hole by centroid-containment (odd number
of enclosing polygons = hole), not by trusting find_contours' winding
convention, which is easy to get backwards from memory.
"""

import json
import numpy as np
from PIL import Image, ImageFilter
from skimage import measure

SRC = "/Users/mannil/Desktop/studio-m/TSONS/card_stand/logo_mask_v5.png"  # hand-cleaned: solid
                        # sun disc+rays and crescent moon redrawn cleanly, replacing the fuzzy
                        # traced versions from the source JPG - mountain/sparkles/dashes unchanged.
                        # Already cropped to icon-only (no text region), 720x376.
OUT = "/Users/mannil/Desktop/studio-m/TSONS/card_stand/logo_contours_v5.json"
BLUR_RADIUS = 0.5      # same value that fixed the heightmap's fuzzy edges - smooths the
                        # marching-squares curves too, not just a heightmap gradient
SIMPLIFY_TOL = 0.4      # pixels, native (183px-wide) res - down from v4's 1.2. The faceted look
                         # was Douglas-Peucker throwing away points, not the underlying marching-
                         # squares trace (which already sub-pixel-interpolates the threshold
                         # crossing and is smooth); keeping more of those points reads noticeably
                         # smoother without artificially rounding genuinely sharp corners (the
                         # mountain's peak, star points) the way corner-cutting smoothing would.
MIN_AREA_PX = 3.0       # drop degenerate slivers (thin decorative lines simplified to ~nothing)
ICON_ROW_FRAC_MAX = 1.0    # no-op now - logo_mask_v5.png is already cropped icon-only, no
                           # wordmark to exclude (text stays real vector, see build_logo_text)


def otsu_threshold(gray):
    hist, _ = np.histogram(gray, bins=256, range=(0, 256))
    total = gray.size
    sum_all = np.dot(np.arange(256), hist)
    sum_bg, weight_bg, best_var, best_t = 0.0, 0, -1.0, 128
    for t in range(256):
        weight_bg += hist[t]
        if weight_bg == 0:
            continue
        weight_fg = total - weight_bg
        if weight_fg == 0:
            break
        sum_bg += t * hist[t]
        mean_bg = sum_bg / weight_bg
        mean_fg = (sum_all - sum_bg) / weight_fg
        var_between = weight_bg * weight_fg * (mean_bg - mean_fg) ** 2
        if var_between > best_var:
            best_var, best_t = var_between, t
    return best_t


def point_in_polygon(px, py, poly):
    n = len(poly)
    inside = False
    j = n - 1
    for i in range(n):
        xi, yi = poly[i]
        xj, yj = poly[j]
        if (yi > py) != (yj > py):
            x_cross = (xj - xi) * (py - yi) / (yj - yi + 1e-12) + xi
            if px < x_cross:
                inside = not inside
        j = i
    return inside


def interior_probe_point(poly, epsilon=0.5):
    """A point guaranteed to be JUST inside poly, near its own boundary -
    NOT the naive vertex-average centroid, which for a ring shape (the
    outer edge of a letter like "O") lands in the middle of its own
    counter/hole rather than in the actual ink. That bug showed up as
    the O's outer boundary getting misclassified as a hole itself,
    because its centroid tested positive for "inside" the O's separate
    inner-counter contour."""
    n = len(poly)
    for i in range(n):
        x1, y1 = poly[i]
        x2, y2 = poly[(i + 1) % n]
        dx, dy = x2 - x1, y2 - y1
        length = (dx ** 2 + dy ** 2) ** 0.5
        if length < 1e-9:
            continue
        mx, my = (x1 + x2) / 2.0, (y1 + y2) / 2.0
        nx, ny = -dy / length, dx / length
        for sign in (1, -1):
            px, py = mx + sign * nx * epsilon, my + sign * ny * epsilon
            if point_in_polygon(px, py, poly):
                return px, py
    return (sum(p[0] for p in poly) / n, sum(p[1] for p in poly) / n)


def main():
    im = Image.open(SRC).convert("L")
    gray = np.array(im)
    t = otsu_threshold(gray)
    binary = np.where(gray < t, 255, 0).astype(np.uint8)  # ink=255 (foreground)

    bw = Image.fromarray(binary, mode="L")
    bw_blurred = bw.filter(ImageFilter.GaussianBlur(radius=BLUR_RADIUS))
    arr = np.array(bw_blurred).astype(np.float64)
    h, w = arr.shape

    raw_contours = measure.find_contours(arr, level=127.5)
    raw_polys = []
    for c in raw_contours:
        simplified = measure.approximate_polygon(c, tolerance=SIMPLIFY_TOL)
        if len(simplified) < 3:
            continue
        poly = [(col, row) for row, col in simplified]  # (x, y) pixel space
        area = 0.5 * abs(sum(
            poly[i][0] * poly[(i + 1) % len(poly)][1] - poly[(i + 1) % len(poly)][0] * poly[i][1]
            for i in range(len(poly))
        ))
        if area < MIN_AREA_PX:
            continue  # degenerate sliver from simplifying a very thin decorative line
        if min(y for _, y in poly) / h > ICON_ROW_FRAC_MAX:
            continue  # the wordmark - rebuilt separately as real text
        raw_polys.append(poly)

    tagged = []
    for i, poly in enumerate(raw_polys):
        cx, cy = interior_probe_point(poly)
        contained_count = sum(
            1 for j, other in enumerate(raw_polys) if i != j and point_in_polygon(cx, cy, other)
        )
        tagged.append((poly, contained_count % 2 == 1))

    polygons_out = []
    for poly, is_hole in tagged:
        pts_uv = [[x / w, 1.0 - y / h] for (x, y) in poly]  # v flipped so it increases upward
        polygons_out.append({"points": pts_uv, "hole": is_hole})

    with open(OUT, "w") as f:
        json.dump(polygons_out, f)
    n_holes = sum(1 for p in polygons_out if p["hole"])
    print(f"Saved {len(polygons_out)} contours ({n_holes} holes) to {OUT}")


if __name__ == "__main__":
    main()

Technique 2: zero-support printing
#

Earlier versions stood the backplate on edge to fit the printer’s bed, which meant supports under the overhanging relief. It’s printed flat on its back instead — every raised feature (the logo, the wordmark, the decorative frame) projects in the same direction, straight up off the plate, so nothing overhangs and nothing needs support material at all. The tradeoff is bed footprint: flat-on-back only works because the backplate is narrow enough (86 × 138mm) to fit the Photon Mono 2’s 89.6 × 143.4mm bed directly.

The holder is its own separate script — same zero-support flat-printing idea, just on a different axis of the part.

backplate.py
"""
Store-counter backplate, back to the card-holder pairing (Blender bpy) -
standalone, NOT terrain.

v8 change: forked from v7 (card_stand_v7_trophy.py, the "#1" trophy
plaque). Returns to the original card-stand idea (a Mythos backplate
paired with a separate card-holder piece, like v5/v6) but keeps every
structural lesson learned building the trophy:

1. #1 and the subtitle are gone - back to just icon + MYTHOS wordmark,
   like v6's backplate-solo.
2. The decorative ridge goes back to an upside-down U (top + both sides,
   no bottom stroke) - the SAME reasoning as v5/v6 originally had for
   this shape: the card holder's own back fin will sit flush against the
   lower portion of the backplate once glued, and a ridge running that
   low would collide with it. v7 had closed this into a full loop only
   because there was no holder to collide with there anymore - now
   there is again.
3. TENON_* - a pair of tenons protruding from the border zone's front
   face, in the lower-middle area the ridge no longer covers, keying
   into the EXISTING 70mm card holder's back fin sockets (built in
   card_stand_v5_flat_sharp_logo.py's build_holder()). This backplate
   is now 86mm wide (grown across the trophy iterations) - decided to
   keep reusing that 70mm holder rather than widen a matching one, so
   the backplate simply overhangs it on both sides. TENON_* values are
   copied EXACTLY from that script's own constants (position, size, fit
   clearance), not just eyeballed close, since the physical fit depends
   on both sides agreeing precisely.
4. DISH_DEPTH's own recess now stops at the same halfway line as the
   ridge, instead of running the full height like v7's standalone
   plaque - the holder's back fin needs a genuinely flat mating surface
   down there, not a recessed one.

Everything else carries over from v7 as-is: the flush border/text dish
(DISH_DEPTH matched to EMBOSS_H), the thinned BACKPLATE_T (5mm), the
genuine Bold Baskerville face (no synthetic curve offset - see
BOLD_OFFSET's own history for why that was abandoned), the back-edge
release chamfer, and the volume-growth safety assert after every union.

Run:
  /Applications/Blender.app/Contents/MacOS/Blender --background --python card_stand_v8_holder_return.py
"""

import bpy
import bmesh
import json
import math
import mathutils
import os

# ============================================================
# CONFIG (all mm)
# ============================================================

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))

EXPORT_DIR = os.path.join(SCRIPT_DIR, "output_v8")
EXPORT_STL = True

RENDER_IMAGES = True
RENDER_DIR = os.path.join(EXPORT_DIR, "renders")
RENDER_RESOLUTION = (1600, 1200)
RENDER_ANGLES = {
    "front": (0.0, -1.0, 0.35),
    "side": (1.0, -0.4, 0.35),
    "top": (0.05, -0.3, 1.0),
    "iso": (0.7, -1.0, 0.55),
}

# --- Backplate ---
BACKPLATE_W = 86.0
BACKPLATE_H = 138.0
BACKPLATE_T = 5.0                   # thinned from v7's 6.0 - CENTER_T (panel thickness after the
                                     # dish) is 3.0mm, a healthy 1.5mm margin over EMBOSS_EMBED's
                                     # 1.5mm bonding depth. Real remaining risk at this thickness is
                                     # warping during cure and handling durability, not bonding -
                                     # neither is something these mesh/boolean checks can catch.
DISH_DEPTH = 2.0                    # panel recesses this far from the border's own front face -
                                     # matched to EMBOSS_H so the border and the text's own tip land
                                     # flush, rather than the border sitting proud of the text.
CENTER_T = BACKPLATE_T - DISH_DEPTH # panel's own remaining thickness after the dish
BACKPLATE_BEVEL_W = 3.0

# Width of the solid full-thickness frame around the plate's own edge,
# measured past the ridge's own outer legs (RIDGE_INSET + RIDGE_WIDTH)
# so the ridge sits entirely on solid material and the dish starts just
# inside it.
FRAME_MARGIN_PAST_RIDGE = 2.0

EMBOSS_H = 2.0                      # flat raised height off the backplate face - kept at v7's
                                     # halved value (was 3.0-4.0 originally, which is what actually
                                     # delaminated mid-print - see the site post's own history)
EMBOSS_EMBED = 1.5                  # extra embed into the backplate for a clean union
CUTTER_OVERSHOOT = 1.0

# curve_data.offset for the wordmark - kept at 0.0. A synthetic bold via
# this offset proved unstable once text is genuinely bonded to the shell
# (self-intersecting input can confuse the boolean solver badly enough
# to carve away existing shell material, not just fail to add the new
# piece - see assert_volume_grew's own comment). Real boldness now comes
# from TEXT_FONT_PATH being an actual Bold face instead.
BOLD_OFFSET = 0.0

# --- Decorative ridge - back to v5/v6's upside-down U (top + both
# sides, no bottom stroke), not v7's full loop - the holder's own back
# fin sits flush against the lower portion of the backplate once glued,
# and a ridge running that low would collide with it. ---
RIDGE_INSET = 4.0
RIDGE_WIDTH = 3.0
RIDGE_HEIGHT = 1.5

# Where the solid full-thickness frame ends and the recessed center
# panel begins - past the ridge's own outer edge, see
# FRAME_MARGIN_PAST_RIDGE above.
FRAME_INSET = RIDGE_INSET + RIDGE_WIDTH + FRAME_MARGIN_PAST_RIDGE
PANEL_W = BACKPLATE_W - 2 * FRAME_INSET

# How far the panel recesses from the front - relief inside it
# (icon/wordmark) is built off this Y instead of the plain 0 the
# border-zone ridge/nubs still use.
CENTER_FRONT_Y = BACKPLATE_T - CENTER_T

# Text pieces use Blender's curve_data.extrude, which extrudes
# SYMMETRICALLY around the curve's own center plane - unlike the icon's
# _extrude_profile, whose `offset` param IS the front tip already. This
# is the corrected location Y that lines up the text's own front tip
# with CENTER_FRONT_Y - EMBOSS_H, same as the icon (see v7's own history
# for the bug this fixes - text used to sit proud of the icon and, more
# importantly, float unbonded above the panel surface).
TEXT_FRONT_Y = CENTER_FRONT_Y + (EMBOSS_EMBED - EMBOSS_H) / 2.0

# Small chamfer around the BACK perimeter edge only, on top of the
# decorative BACKPLATE_BEVEL_W bevel - a resin-print release aid.
RELEASE_CHAMFER_W = 1.0

# --- Logo icon ---
LOGO_CONTOURS_PATH = os.path.join(SCRIPT_DIR, "logo_contours_v5.json")
LOGO_W = 58.0                # back to a size that comfortably fits PANEL_W with real margin - v7
                              # pushed this to the panel's own ceiling for a standalone plaque with
                              # nothing else competing for width; this one shares the plate with a
                              # holder-attachment area below, so isn't chasing max size the same way
LOGO_SIDE_MARGIN = 4.0
LOGO_ASPECT = 376.0 / 720.0
LOGO_CENTER_X = 0.0
LOGO_TOP_MARGIN = 12.0

# --- MYTHOS wordmark ---
TEXT_FONT_PATH = os.path.join(SCRIPT_DIR, "BaskervilleBold.ttf")
                            # genuine Bold face (extracted from Baskerville.ttc via fontTools,
                            # since Blender's font loader can't address a face within a .ttc
                            # directly) - real drawn-bold strokes, no self-intersection risk since
                            # the outline is wider by design, not synthetically expanded.
TEXT_STRING = "MYTHOS"
TEXT_SIZE = 15.0
TEXT_GAP_BELOW_ICON = 6.0
TEXT_SIDE_MARGIN = 4.0

# --- Attachment tenons - a pair of ROUND pegs protruding from the
# border zone's own front face (solid full BACKPLATE_T material, not the
# thinner recessed panel), keying into the card holder piece's back fin.
# Round instead of the original v5/v6 rectangular tenon: once the holder
# prints with its width axis vertical (see the resin-print orientation
# discussion this session - that axis change is what finally kills the
# wedge-cavity overhang that caused the earlier deformed/scarred print),
# the tenon SOCKETS become the one feature that isn't part of the
# constant cross-section, and a rectangular socket's flat top edge is a
# small unsupported bridge. A round hole with its axis horizontal
# doesn't have that problem - the void narrows to a point at the top
# following the circle's own curve instead of presenting a flat ceiling,
# so it self-supports. Position/offset/embed values still match the
# holder's own TENON_OFFSET_X/TENON_Z_CENTER - only the cross-section
# shape changed, not where it sits. The holder's own socket needs to be
# rebuilt round to match whenever that script gets updated. ---
TENON_OFFSET_X = 17.0       # distance from center X for each of the pair - matches the holder's
                             # own TENON_OFFSET_X exactly
TENON_DIAMETER = 8.0         # nominal diameter before fit clearance
TENON_SOCKET_DEPTH = 4.0     # matches the holder socket's own cut depth
TENON_FIT_CLEARANCE = 0.25   # shrinks the tenon (not the socket) for an easy slip fit
TENON_EMBED = 1.0            # how far the tenon embeds into the backplate's own front face for a
                             # clean union, beyond the plain protrusion
TENON_Z_CENTER = 28.0        # matches the holder socket's own Z position exactly
TENON_PROTRUDE_LEN = TENON_SOCKET_DEPTH - 0.5   # protrudes slightly less than the socket's own cut
                                                 # depth so the tenon doesn't bottom out before the
                                                 # two flat faces meet - same margin the original
                                                 # holder/backplate pairing used

# ============================================================
# SANITY CHECKS
# ============================================================
assert BACKPLATE_W <= 89.0, "backplate wider than the Photon Mono 2 bed's 89.6mm axis (flat print)"
assert BACKPLATE_H <= 143.0, "backplate taller than the Photon Mono 2 bed's 143.4mm axis (flat print)"
assert CENTER_T > EMBOSS_EMBED, "dished middle thinner than the relief's own embed depth"
assert LOGO_W <= PANEL_W - 2 * LOGO_SIDE_MARGIN, \
    "logo icon wider than the recessed panel - it'll touch the frame, see PANEL_W"
assert TENON_OFFSET_X + TENON_DIAMETER / 2.0 < BACKPLATE_W / 2.0 - RIDGE_INSET, \
    "tenon runs past the ridge's own inner edge"
assert 0.0 < TENON_Z_CENTER - TENON_DIAMETER / 2.0, "tenon runs off the bottom of the plate"
assert TENON_OFFSET_X + TENON_DIAMETER / 2.0 < 70.0 / 2.0, \
    "tenon runs past the 70mm holder's own edge - it must stay within the narrower holder's width"


# ============================================================
# GENERIC HELPERS (shared conventions - see pump_adapter.py, columns.py)
# ============================================================

def clear_scene():
    bpy.ops.object.select_all(action='SELECT')
    bpy.ops.object.delete()
    for block in list(bpy.data.meshes):
        bpy.data.meshes.remove(block)


def apply_boolean(target, cutter, operation):
    mod = target.modifiers.new("Bool", 'BOOLEAN')
    mod.object = cutter
    mod.operation = operation
    mod.solver = 'EXACT'
    bpy.context.view_layer.objects.active = target
    bpy.ops.object.modifier_apply(modifier=mod.name)
    bpy.data.objects.remove(cutter, do_unlink=True)
    return target


def union_onto(base, piece):
    return apply_boolean(base, piece, 'UNION')


def apply_transform(obj):
    bpy.ops.object.select_all(action='DESELECT')
    obj.select_set(True)
    bpy.context.view_layer.objects.active = obj
    bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)


def chamfer_back_edge(obj, back_y, width):
    """Flat chamfer (1-segment bevel) around just the object's back-face
    (y == back_y) perimeter edges - a print-plate release aid. Must run
    while obj is still a plain box (8 verts, back face trivially
    identified by Y) - called right after build_box, before the ridge/
    pocket/relief/nubs turn it into something more complex to select
    edges on."""
    bm = bmesh.new()
    bm.from_mesh(obj.data)
    bm.verts.ensure_lookup_table()
    bm.edges.ensure_lookup_table()
    back_edges = [e for e in bm.edges
                  if all(abs(v.co.y - back_y) < 1e-6 for v in e.verts)]
    assert len(back_edges) == 4, f"expected 4 back-face edges on a plain box, found {len(back_edges)}"
    bmesh.ops.bevel(bm, geom=back_edges, offset=width, offset_type='OFFSET', segments=1,
                     affect='EDGES')
    bm.to_mesh(obj.data)
    bm.free()
    return obj


def apply_bevel(obj, width, segments=2):
    mod = obj.modifiers.new("Bevel", 'BEVEL')
    mod.width = width
    mod.segments = segments
    mod.limit_method = 'ANGLE'
    bpy.context.view_layer.objects.active = obj
    bpy.ops.object.modifier_apply(modifier=mod.name)
    return obj


def mesh_volume(obj):
    bm = bmesh.new()
    bm.from_mesh(obj.data)
    bm.transform(obj.matrix_world)
    volume = bm.calc_volume()
    bm.free()
    return volume


def nonmanifold_fraction(obj):
    bm = bmesh.new()
    bm.from_mesh(obj.data)
    bad = sum(1 for e in bm.edges if not e.is_manifold)
    total = len(bm.edges)
    bm.free()
    return bad / total if total else 0.0


def build_box(sx, sy, sz, center, name):
    """Box spanning [center - size/2, center + size/2] on each axis."""
    bm = bmesh.new()
    bmesh.ops.create_cube(bm, size=1.0)
    bmesh.ops.scale(bm, vec=(sx, sy, sz), verts=bm.verts)
    bmesh.ops.translate(bm, vec=center, verts=bm.verts)
    mesh = bpy.data.meshes.new(name)
    bm.to_mesh(mesh)
    bm.free()
    obj = bpy.data.objects.new(name, mesh)
    bpy.context.collection.objects.link(obj)
    return obj


def build_cylinder(diameter, length, center, name, segments=24):
    """Cylinder of given diameter/length centered at `center`, axis along
    world Y (the tenon's own protrusion direction) - round pegs/sockets
    with a horizontal axis print without support (the void narrows to a
    point at the top following the circle's own curve, unlike a box
    hole's flat overhanging top edge). bmesh's create_cone builds along
    local Z by default, so rotate 90deg around X to lay it onto Y before
    placing it."""
    bm = bmesh.new()
    bmesh.ops.create_cone(bm, cap_ends=True, cap_tris=False, segments=segments,
                           radius1=diameter / 2.0, radius2=diameter / 2.0, depth=length)
    bmesh.ops.rotate(bm, verts=bm.verts, cent=(0.0, 0.0, 0.0),
                      matrix=mathutils.Matrix.Rotation(math.radians(90.0), 3, 'X'))
    bmesh.ops.translate(bm, vec=center, verts=bm.verts)
    mesh = bpy.data.meshes.new(name)
    bm.to_mesh(mesh)
    bm.free()
    obj = bpy.data.objects.new(name, mesh)
    bpy.context.collection.objects.link(obj)
    return obj


def _extrude_profile(points, plane, offset, thickness, name):
    """Build a flat face from a closed 2D point loop and extrude it into a
    solid prism. `plane` is 'XZ' (points are (x, z), extrude along Y) or
    'YZ' (points are (y, z), extrude along X). `offset` is the fixed
    coordinate of the starting face; extrusion runs +thickness from there."""
    bm = bmesh.new()
    if plane == 'XZ':
        verts = [bm.verts.new((p[0], offset, p[1])) for p in points]
    else:
        verts = [bm.verts.new((offset, p[0], p[1])) for p in points]
    face = bm.faces.new(verts)
    result = bmesh.ops.extrude_face_region(bm, geom=[face])
    new_verts = [v for v in result['geom'] if isinstance(v, bmesh.types.BMVert)]
    vec = (0.0, thickness, 0.0) if plane == 'XZ' else (thickness, 0.0, 0.0)
    bmesh.ops.translate(bm, vec=vec, verts=new_verts)
    bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
    bm.normal_update()
    mesh = bpy.data.meshes.new(name)
    bm.to_mesh(mesh)
    bm.free()
    obj = bpy.data.objects.new(name, mesh)
    bpy.context.collection.objects.link(obj)
    return obj


def compute_scene_bounds():
    xs, ys, zs = [], [], []
    for obj in bpy.context.scene.objects:
        if obj.type != 'MESH':
            continue
        for corner in obj.bound_box:
            world_corner = obj.matrix_world @ mathutils.Vector(corner)
            xs.append(world_corner.x)
            ys.append(world_corner.y)
            zs.append(world_corner.z)
    if not xs:
        return mathutils.Vector((0.0, 0.0, 0.0)), 10.0
    center = mathutils.Vector((
        (min(xs) + max(xs)) / 2,
        (min(ys) + max(ys)) / 2,
        (min(zs) + max(zs)) / 2,
    ))
    size = max(max(xs) - min(xs), max(ys) - min(ys), max(zs) - min(zs))
    return center, size


def setup_camera_and_light(center):
    cam_data = bpy.data.cameras.new("RenderCam")
    cam_obj = bpy.data.objects.new("RenderCam", cam_data)
    bpy.context.collection.objects.link(cam_obj)

    target = bpy.data.objects.new("RenderTarget", None)
    target.location = center
    bpy.context.collection.objects.link(target)

    track = cam_obj.constraints.new(type='TRACK_TO')
    track.target = target
    track.track_axis = 'TRACK_NEGATIVE_Z'
    track.up_axis = 'UP_Y'

    key_dir = mathutils.Vector((0.6, 0.45, -0.6)).normalized()
    key_data = bpy.data.lights.new("RenderKey", type='SUN')
    key_data.energy = 3.0
    key_obj = bpy.data.objects.new("RenderKey", key_data)
    key_obj.rotation_euler = key_dir.to_track_quat('-Z', 'Y').to_euler()
    bpy.context.collection.objects.link(key_obj)

    fill_dir = mathutils.Vector((-0.5, 0.35, 0.35)).normalized()
    fill_data = bpy.data.lights.new("RenderFill", type='SUN')
    fill_data.energy = 0.6
    fill_obj = bpy.data.objects.new("RenderFill", fill_data)
    fill_obj.rotation_euler = fill_dir.to_track_quat('-Z', 'Y').to_euler()
    bpy.context.collection.objects.link(fill_obj)

    bpy.context.scene.camera = cam_obj
    return cam_obj


def render_closeup(obj, name, direction=(0.0, -1.0, 0.0)):
    xs = [ (obj.matrix_world @ mathutils.Vector(c)) for c in obj.bound_box ]
    center = sum(xs, mathutils.Vector((0, 0, 0))) / 8.0
    size = max((max(v[i] for v in xs) - min(v[i] for v in xs)) for i in range(3))
    cam_obj = setup_camera_and_light(center)
    cam_obj.data.lens = 85.0
    scene = bpy.context.scene
    try:
        scene.render.engine = 'BLENDER_EEVEE_NEXT'
    except TypeError:
        scene.render.engine = 'BLENDER_EEVEE'
    scene.render.resolution_x = RENDER_RESOLUTION[0]
    scene.render.resolution_y = RENDER_RESOLUTION[1]
    distance = size * 1.6
    cam_obj.location = center + mathutils.Vector(direction).normalized() * distance
    scene.render.filepath = os.path.join(RENDER_DIR, f"{name}.png")
    bpy.ops.render.render(write_still=True)
    print(f"Rendered {scene.render.filepath}")


def render_angles(center, size):
    os.makedirs(RENDER_DIR, exist_ok=True)
    cam_obj = setup_camera_and_light(center)

    scene = bpy.context.scene
    try:
        scene.render.engine = 'BLENDER_EEVEE_NEXT'
    except TypeError:
        scene.render.engine = 'BLENDER_EEVEE'
    try:
        scene.eevee.use_gtao = True
    except AttributeError:
        pass
    scene.render.resolution_x = RENDER_RESOLUTION[0]
    scene.render.resolution_y = RENDER_RESOLUTION[1]

    distance = size * 3.0
    for name, direction in RENDER_ANGLES.items():
        cam_obj.location = center + mathutils.Vector(direction).normalized() * distance
        scene.render.filepath = os.path.join(RENDER_DIR, f"{name}.png")
        bpy.ops.render.render(write_still=True)
        print(f"Rendered {scene.render.filepath}")


def export_stl(obj, filename):
    bpy.ops.object.select_all(action='DESELECT')
    obj.select_set(True)
    bpy.context.view_layer.objects.active = obj
    path = os.path.join(EXPORT_DIR, filename)
    bpy.ops.wm.stl_export(filepath=path, export_selected_objects=True)
    print(f"Exported {path}")


# ============================================================
# BACKPLATE + LOGO
# ============================================================

def load_logo_contours():
    with open(LOGO_CONTOURS_PATH) as f:
        return json.load(f)


def build_logo_icon(shell):
    contours = load_logo_contours()
    icon_h = LOGO_W * LOGO_ASPECT
    icon_x0 = LOGO_CENTER_X - LOGO_W / 2.0
    icon_z0 = BACKPLATE_H - LOGO_TOP_MARGIN - icon_h

    for c in contours:
        pts = [(icon_x0 + u * LOGO_W, icon_z0 + v * icon_h) for u, v in c["points"]]
        if c["hole"]:
            cutter = _extrude_profile(
                pts, 'XZ', CENTER_FRONT_Y - EMBOSS_H - CUTTER_OVERSHOOT,
                EMBOSS_H + EMBOSS_EMBED + 2 * CUTTER_OVERSHOOT, "icon_hole_cutter")
            apply_boolean(shell, cutter, 'DIFFERENCE')
        else:
            piece = _extrude_profile(pts, 'XZ', CENTER_FRONT_Y - EMBOSS_H,
                                      EMBOSS_H + EMBOSS_EMBED, "icon_piece")
            union_onto(shell, piece)

    return icon_z0


def _build_flat_text_mesh(text, size, offset, font_path=TEXT_FONT_PATH):
    """Real vector text, converted to a mesh, extruded to match
    EMBOSS_H/EMBOSS_EMBED. Returns the still-unpositioned mesh object."""
    font = bpy.data.fonts.load(font_path)
    curve_data = bpy.data.curves.new(f"{text}_curve", type='FONT')
    curve_data.body = text
    curve_data.font = font
    curve_data.size = size
    curve_data.align_x = 'CENTER'
    curve_data.align_y = 'CENTER'
    curve_data.offset = offset
    curve_data.extrude = (EMBOSS_H + EMBOSS_EMBED) / 2.0
    obj = bpy.data.objects.new(text, curve_data)
    bpy.context.collection.objects.link(obj)

    bpy.ops.object.select_all(action='DESELECT')
    obj.select_set(True)
    bpy.context.view_layer.objects.active = obj
    bpy.ops.object.convert(target='MESH')
    return obj


def _print_piece_stats(name, obj):
    vol = mesh_volume(obj)
    nm = nonmanifold_fraction(obj)
    print(f"{name} piece (pre-union): volume={vol:.1f}mm3  non-manifold={nm:.4f}")


def build_logo_text(shell, icon_bottom_z):
    """Real vector text (Baskerville Bold), flat-extruded to match the
    icon's height."""
    obj = _build_flat_text_mesh(TEXT_STRING, TEXT_SIZE, BOLD_OFFSET)

    local_top_y = max(v.co.y for v in obj.data.vertices)
    local_bottom_y = min(v.co.y for v in obj.data.vertices)
    text_w = max(v.co.x for v in obj.data.vertices) - min(v.co.x for v in obj.data.vertices)
    assert text_w <= PANEL_W - 2 * TEXT_SIDE_MARGIN, \
        f"MYTHOS wordmark ({text_w:.1f}mm) touches the recessed panel edge - shrink TEXT_SIZE"
    target_top_z = icon_bottom_z - TEXT_GAP_BELOW_ICON

    obj.rotation_euler = (math.radians(90.0), 0.0, 0.0)
    obj.location = (LOGO_CENTER_X, TEXT_FRONT_Y, target_top_z - local_top_y)
    apply_transform(obj)
    _print_piece_stats("MYTHOS wordmark", obj)
    union_onto(shell, obj)


def build_nubs(shell):
    """Pair of ROUND tenons protruding -Y from the border zone's own
    front face (Y=0, solid full-thickness material) - keys into the card
    holder's back fin sockets. Round rather than the original v5/v6
    rectangular tenon - see TENON_DIAMETER's own comment for why (the
    holder's socket needs a horizontal-axis round hole to self-support
    once it prints with its width axis vertical). Position/embed/protrude
    logic unchanged from the rectangular version, just built as a
    cylinder instead of a box."""
    for sign in (-1, 1):
        tenon = build_cylinder(
            TENON_DIAMETER - 2 * TENON_FIT_CLEARANCE,
            TENON_PROTRUDE_LEN + TENON_EMBED,
            (sign * TENON_OFFSET_X, (TENON_EMBED - TENON_PROTRUDE_LEN) / 2.0, TENON_Z_CENTER),
            "tenon")
        union_onto(shell, tenon)


def build_backplate_box():
    """Plain box + upside-down-U ridge, beveled, then pocketed from the
    FRONT to dish the middle down to CENTER_T - all BEFORE the logo
    stamp is unioned on. Finishes with a small flat chamfer around the
    back edge only, for print-plate release, and the pair of attachment
    nubs in the lower area the ridge no longer covers."""
    shell = build_box(BACKPLATE_W, BACKPLATE_T, BACKPLATE_H,
                       (0.0, BACKPLATE_T / 2.0, BACKPLATE_H / 2.0), "backplate")
    chamfer_back_edge(shell, BACKPLATE_T, RELEASE_CHAMFER_W)

    # Decorative ridge - upside-down U (top + both sides, no bottom
    # stroke) - the holder's own back fin sits flush against the lower
    # portion once glued, and a ridge running that low would collide
    # with it.
    ridge_y_size = RIDGE_HEIGHT + EMBOSS_EMBED
    ridge_y_center = (EMBOSS_EMBED - RIDGE_HEIGHT) / 2.0
    leg_z0, leg_z1 = BACKPLATE_H / 2.0, BACKPLATE_H - RIDGE_INSET
    leg_x = BACKPLATE_W / 2.0 - RIDGE_INSET

    ridge = build_box(RIDGE_WIDTH, ridge_y_size, leg_z1 - leg_z0,
                       (-leg_x, ridge_y_center, (leg_z0 + leg_z1) / 2.0), "ridge_left")
    right_leg = build_box(RIDGE_WIDTH, ridge_y_size, leg_z1 - leg_z0,
                           (leg_x, ridge_y_center, (leg_z0 + leg_z1) / 2.0), "ridge_right")
    top_bar = build_box(2 * leg_x + RIDGE_WIDTH + 1.0, ridge_y_size, RIDGE_WIDTH,
                         (0.0, ridge_y_center, leg_z1), "ridge_top")
    apply_boolean(ridge, top_bar, 'UNION')
    apply_boolean(ridge, right_leg, 'UNION')
    union_onto(shell, ridge)

    apply_bevel(shell, BACKPLATE_BEVEL_W)

    # Dish the middle: pocket the FRONT down to CENTER_T everywhere
    # except a solid full-thickness frame just past the ridge's own
    # outer edge. Stops at the SAME halfway line as the ridge (leg_z0
    # above) rather than running the full height - the holder's back fin
    # needs a genuinely flat mating surface down there, not a recessed
    # one, same reasoning as the ridge stopping there. The BACK face is
    # untouched regardless (cut only reaches as far as y=CENTER_FRONT_Y),
    # so it stays one continuous flat plane for the flat-on-back print.
    # Cut AFTER the bevel so the pocket's own walls stay sharp.
    overshoot = 2.0
    pocket_z0, pocket_z1 = leg_z0, BACKPLATE_H - FRAME_INSET
    pocket = build_box(
        BACKPLATE_W - 2 * FRAME_INSET,
        CENTER_FRONT_Y + overshoot,
        pocket_z1 - pocket_z0,
        (0.0, (CENTER_FRONT_Y - overshoot) / 2.0, (pocket_z0 + pocket_z1) / 2.0),
        "dish_pocket")
    apply_boolean(shell, pocket, 'DIFFERENCE')

    build_nubs(shell)

    return shell


def assert_volume_grew(shell, prev_volume, step_name):
    """Every relief step should be a net UNION onto the shell, so volume
    should only ever increase step over step. A drop means the boolean
    solver corrupted the shell rather than just failing to add the new
    piece - see card_stand_v7_trophy.py's own history for the exact
    failure mode this catches (self-intersecting input confusing the
    solver's inside/outside classification badly enough to carve away
    existing material)."""
    vol = mesh_volume(shell)
    assert vol > prev_volume, (
        f"{step_name} DECREASED total volume ({prev_volume:.1f} -> {vol:.1f}mm3) - "
        f"the boolean union likely corrupted the shell rather than just failing to add "
        f"material."
    )
    return vol


def build_backplate():
    shell = build_backplate_box()
    vol = mesh_volume(shell)
    icon_bottom_z = build_logo_icon(shell)
    vol = assert_volume_grew(shell, vol, "icon")
    build_logo_text(shell, icon_bottom_z)
    assert_volume_grew(shell, vol, "wordmark")
    return shell


def rotate_for_print(obj):
    """Bakes the flat-on-back print orientation into the exported mesh,
    same idea (and same fix) as card_stand_v8_holder_piece.py's own
    rotate_for_print. The model is built with Z as the tall dimension
    (matching how it reads on screen - text running up the plate) and Y
    as the thin front-to-back thickness (BACKPLATE_T, a few mm). Without
    this, a slicer loading the raw STL - which defaults to treating the
    mesh's own Z as the vertical build axis - sees the TALL dimension as
    vertical and the part loads standing upright on its edge instead of
    lying flat on its back. Rotating -90deg around X maps old Y
    (thickness) to new Z (the print's actual vertical axis) and old Z
    (height) to a horizontal axis instead - and, checked against the
    sign convention used throughout this file (front/relief at low Y,
    back at Y=BACKPLATE_T), lands the flat, relief-free BACK face at the
    bottom (touching the bed) and the relief at the top, which is the
    orientation the whole zero-support design assumes."""
    obj.rotation_euler = (math.radians(-90.0), 0.0, 0.0)
    apply_transform(obj)
    return obj


# ============================================================
# MAIN
# ============================================================

def main():
    os.makedirs(EXPORT_DIR, exist_ok=True)
    clear_scene()

    backplate = build_backplate()
    rotate_for_print(backplate)

    vol = mesh_volume(backplate)
    nm = nonmanifold_fraction(backplate)
    bbox = [backplate.matrix_world @ mathutils.Vector(c) for c in backplate.bound_box]
    xs, ys, zs = [v.x for v in bbox], [v.y for v in bbox], [v.z for v in bbox]
    print(f"backplate: volume={vol:.1f}mm3  non-manifold edge fraction={nm:.4f}")
    print(f"post-rotation bounds: X={max(xs)-min(xs):.1f}mm  Y={max(ys)-min(ys):.1f}mm  "
          f"Z={max(zs)-min(zs):.1f}mm  (Z is the new build height - a few mm over BACKPLATE_T's "
          f"{BACKPLATE_T:.1f}mm, since the relief and tenons protrude past the plain box)")
    assert vol > 0.0, "backplate has zero/negative volume - a boolean likely emptied it"

    if EXPORT_STL:
        export_stl(backplate, "backplate.stl")

    if RENDER_IMAGES:
        center_pt, size = compute_scene_bounds()
        render_angles(center_pt, size)
        render_closeup(backplate, "logo_closeup")

    print("Done.")


if __name__ == "__main__":
    main()
card_holder.py
"""
Standing-card holder - the second part of the v8 pairing (Blender bpy) -
standalone, NOT terrain.

Companion to card_stand_v8_holder_return.py (the backplate). Same comb
geometry as the original v5 holder (card_stand_v5_flat_sharp_logo.py's
build_holder()) - flat stepped-tread base, 4 upright fins forming 3
card slots, a diagonal wedge cut from the underside for resin savings -
copied verbatim since that shape itself was never the problem. Two real
changes, both from this session's print-failure debugging:

1. ROUND tenon sockets instead of the original rectangular ones - see
   card_stand_v8_holder_return.py's TENON_DIAMETER comment for the full
   reasoning. Short version: once this prints in the reoriented axis
   (below), the sockets are the one feature that breaks the shape's
   otherwise-constant cross-section, and a round hole with a horizontal
   axis self-supports (tapers to a point) where a rectangular hole's
   flat top edge doesn't (needs a support bridge).
2. BAKED-IN PRINT ORIENTATION. The original holder's wedge cavity is
   what caused the real failure: printed with HOLDER_W (X) horizontal
   and the comb standing up in Z, the wedge is a large diagonal
   overhang, and partial supports on it came out deformed and scarred.
   But the whole shape is a prismatic extrusion along X (see
   _extrude_profile's own call in build_holder) - the cross-section
   never changes along that axis. Printing with X VERTICAL instead means
   every layer is an identical copy of the same profile: no overhang
   anywhere except the two round sockets, which self-support per point
   1. Rather than rely on remembering to rotate 90 degrees in the
   slicer every time, that rotation is baked into the exported mesh
   here (rotate_for_print, applied right before the STL is written) -
   the file opens already in the correct orientation.

TENON_* values are copied EXACTLY from card_stand_v8_holder_return.py's
own constants (position, diameter, depth) - not just close, since the
physical fit depends on both sides agreeing precisely. If that script's
TENON_* ever change, update these to match.

Run:
  /Applications/Blender.app/Contents/MacOS/Blender --background --python card_stand_v8_holder_piece.py
"""

import bpy
import bmesh
import math
import mathutils
import os

# ============================================================
# CONFIG (all mm)
# ============================================================

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))

EXPORT_DIR = os.path.join(SCRIPT_DIR, "output_v8")
EXPORT_STL = True

RENDER_IMAGES = True
RENDER_DIR = os.path.join(EXPORT_DIR, "renders")
RENDER_RESOLUTION = (1600, 1200)
RENDER_ANGLES = {
    "front": (0.0, -1.0, 0.35),
    "side": (1.0, -0.4, 0.35),
    "top": (0.05, -0.3, 1.0),
    "iso": (0.7, -1.0, 0.55),
}

# --- Card / pile - unchanged from v5 ---
CARD_SIZE = 65.0
CARD_THICKNESS = 0.45                          # ~300gsm cardstock
CARDS_PER_PILE = 50
STACK_H = CARD_THICKNESS * CARDS_PER_PILE      # 22.5mm

# --- Standing-card holder (comb: flat base + 4 upright fins = 3 slots) - unchanged from v5 ---
SLOT_CLEARANCE = 0.5                           # per side, cards slide freely
HOLDER_W = 70.0                                # matches the ORIGINAL 70mm backplate width - this
                                                # holder is being reused as-is rather than widened to
                                                # match the current (86mm) backplate, see
                                                # card_stand_v8_holder_return.py's own TENON_* comment
STANDING_SLOT_GAP = STACK_H + 2 * SLOT_CLEARANCE   # 23.5mm
FIN_THICK = 3.0
BACK_FIN_THICK = 6.0
BASE_THICK = 4.0
FIN_HEIGHT = 24.0
STEP_RISE = 7.0

FLOOR_HS = [BASE_THICK + i * STEP_RISE for i in range(3)]

# --- Alignment joint (backplate <-> holder's back fin) - TENON_* copied
# exactly from card_stand_v8_holder_return.py. DIAMETER here is the
# nominal (un-shrunk) size - the backplate's tenon is the one that gets
# TENON_FIT_CLEARANCE subtracted for the slip fit, not this socket. ---
TENON_OFFSET_X = 17.0
TENON_DIAMETER = 8.0
TENON_SOCKET_DEPTH = 4.0
TENON_Z_CENTER = 28.0

# --- Resin-saving wedge carved into the underside - unchanged from v5 ---
HOLLOW_SHELL_T = 2.0

FINISH_BEVEL_W = 0.3                # holder's edge bevel

# ============================================================
# SANITY CHECKS
# ============================================================
HOLDER_DEPTH = 3 * FIN_THICK + 3 * STANDING_SLOT_GAP + BACK_FIN_THICK
BACK_FIN_TOP_Z = FLOOR_HS[-1] + FIN_HEIGHT   # tallest point of the comb itself (old Z, pre-rotation)

assert STEP_RISE < FIN_HEIGHT, \
    "a step taller than the fin itself would bury the divider - lower STEP_RISE or raise FIN_HEIGHT"
assert 0.0 < TENON_Z_CENTER < BACK_FIN_TOP_Z, "tenon socket falls outside the back fin's own height"
assert TENON_OFFSET_X + TENON_DIAMETER / 2 < HOLDER_W / 2 - 5.0, \
    "tenon socket runs past the back fin's own edge"
# Bed-fit check for the REORIENTED print (HOLDER_W vertical, HOLDER_DEPTH x
# BACK_FIN_TOP_Z as the bed-plane footprint) - different axes than the
# original v5 assert, which checked the old flat-on-comb orientation.
assert HOLDER_DEPTH <= 89.0, "holder deeper than the Photon Mono 2's 89.6mm bed axis (with margin)"
assert BACK_FIN_TOP_Z <= 143.0, \
    "holder taller (old Z) than the Photon Mono 2's 143.4mm bed axis"
# HOLDER_W (70mm) becomes the print's own build height in this orientation - comfortably within
# any resin printer's Z travel (this printer class is typically 155mm+), not asserted here since
# that figure isn't independently confirmed the way the XY bed size is.


# ============================================================
# GENERIC HELPERS (shared conventions - see pump_adapter.py, columns.py)
# ============================================================

def clear_scene():
    bpy.ops.object.select_all(action='SELECT')
    bpy.ops.object.delete()
    for block in list(bpy.data.meshes):
        bpy.data.meshes.remove(block)


def apply_boolean(target, cutter, operation):
    mod = target.modifiers.new("Bool", 'BOOLEAN')
    mod.object = cutter
    mod.operation = operation
    mod.solver = 'EXACT'
    bpy.context.view_layer.objects.active = target
    bpy.ops.object.modifier_apply(modifier=mod.name)
    bpy.data.objects.remove(cutter, do_unlink=True)
    return target


def apply_transform(obj):
    bpy.ops.object.select_all(action='DESELECT')
    obj.select_set(True)
    bpy.context.view_layer.objects.active = obj
    bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)


def apply_bevel(obj, width, segments=2):
    mod = obj.modifiers.new("Bevel", 'BEVEL')
    mod.width = width
    mod.segments = segments
    mod.limit_method = 'ANGLE'
    bpy.context.view_layer.objects.active = obj
    bpy.ops.object.modifier_apply(modifier=mod.name)
    return obj


def mesh_volume(obj):
    bm = bmesh.new()
    bm.from_mesh(obj.data)
    bm.transform(obj.matrix_world)
    volume = bm.calc_volume()
    bm.free()
    return volume


def nonmanifold_fraction(obj):
    bm = bmesh.new()
    bm.from_mesh(obj.data)
    bad = sum(1 for e in bm.edges if not e.is_manifold)
    total = len(bm.edges)
    bm.free()
    return bad / total if total else 0.0


def build_box(sx, sy, sz, center, name):
    """Box spanning [center - size/2, center + size/2] on each axis."""
    bm = bmesh.new()
    bmesh.ops.create_cube(bm, size=1.0)
    bmesh.ops.scale(bm, vec=(sx, sy, sz), verts=bm.verts)
    bmesh.ops.translate(bm, vec=center, verts=bm.verts)
    mesh = bpy.data.meshes.new(name)
    bm.to_mesh(mesh)
    bm.free()
    obj = bpy.data.objects.new(name, mesh)
    bpy.context.collection.objects.link(obj)
    return obj


def build_cylinder(diameter, length, center, name, segments=24):
    """Cylinder of given diameter/length centered at `center`, axis along
    world Y (the tenon's own protrusion/socket direction) - matches
    card_stand_v8_holder_return.py's build_cylinder exactly; round holes
    with a horizontal axis self-support (taper to a point) instead of
    presenting a flat overhanging top edge like a box socket would."""
    bm = bmesh.new()
    bmesh.ops.create_cone(bm, cap_ends=True, cap_tris=False, segments=segments,
                           radius1=diameter / 2.0, radius2=diameter / 2.0, depth=length)
    bmesh.ops.rotate(bm, verts=bm.verts, cent=(0.0, 0.0, 0.0),
                      matrix=mathutils.Matrix.Rotation(math.radians(90.0), 3, 'X'))
    bmesh.ops.translate(bm, vec=center, verts=bm.verts)
    mesh = bpy.data.meshes.new(name)
    bm.to_mesh(mesh)
    bm.free()
    obj = bpy.data.objects.new(name, mesh)
    bpy.context.collection.objects.link(obj)
    return obj


def _extrude_profile(points, plane, offset, thickness, name):
    """Build a flat face from a closed 2D point loop and extrude it into a
    solid prism. `plane` is 'XZ' (points are (x, z), extrude along Y) or
    'YZ' (points are (y, z), extrude along X). `offset` is the fixed
    coordinate of the starting face; extrusion runs +thickness from there."""
    bm = bmesh.new()
    if plane == 'XZ':
        verts = [bm.verts.new((p[0], offset, p[1])) for p in points]
    else:
        verts = [bm.verts.new((offset, p[0], p[1])) for p in points]
    face = bm.faces.new(verts)
    result = bmesh.ops.extrude_face_region(bm, geom=[face])
    new_verts = [v for v in result['geom'] if isinstance(v, bmesh.types.BMVert)]
    vec = (0.0, thickness, 0.0) if plane == 'XZ' else (thickness, 0.0, 0.0)
    bmesh.ops.translate(bm, vec=vec, verts=new_verts)
    bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
    bm.normal_update()
    mesh = bpy.data.meshes.new(name)
    bm.to_mesh(mesh)
    bm.free()
    obj = bpy.data.objects.new(name, mesh)
    bpy.context.collection.objects.link(obj)
    return obj


def compute_scene_bounds():
    xs, ys, zs = [], [], []
    for obj in bpy.context.scene.objects:
        if obj.type != 'MESH':
            continue
        for corner in obj.bound_box:
            world_corner = obj.matrix_world @ mathutils.Vector(corner)
            xs.append(world_corner.x)
            ys.append(world_corner.y)
            zs.append(world_corner.z)
    if not xs:
        return mathutils.Vector((0.0, 0.0, 0.0)), 10.0
    center = mathutils.Vector((
        (min(xs) + max(xs)) / 2,
        (min(ys) + max(ys)) / 2,
        (min(zs) + max(zs)) / 2,
    ))
    size = max(max(xs) - min(xs), max(ys) - min(ys), max(zs) - min(zs))
    return center, size


def setup_camera_and_light(center):
    cam_data = bpy.data.cameras.new("RenderCam")
    cam_obj = bpy.data.objects.new("RenderCam", cam_data)
    bpy.context.collection.objects.link(cam_obj)

    target = bpy.data.objects.new("RenderTarget", None)
    target.location = center
    bpy.context.collection.objects.link(target)

    track = cam_obj.constraints.new(type='TRACK_TO')
    track.target = target
    track.track_axis = 'TRACK_NEGATIVE_Z'
    track.up_axis = 'UP_Y'

    key_dir = mathutils.Vector((0.6, 0.45, -0.6)).normalized()
    key_data = bpy.data.lights.new("RenderKey", type='SUN')
    key_data.energy = 3.0
    key_obj = bpy.data.objects.new("RenderKey", key_data)
    key_obj.rotation_euler = key_dir.to_track_quat('-Z', 'Y').to_euler()
    bpy.context.collection.objects.link(key_obj)

    fill_dir = mathutils.Vector((-0.5, 0.35, 0.35)).normalized()
    fill_data = bpy.data.lights.new("RenderFill", type='SUN')
    fill_data.energy = 0.6
    fill_obj = bpy.data.objects.new("RenderFill", fill_data)
    fill_obj.rotation_euler = fill_dir.to_track_quat('-Z', 'Y').to_euler()
    bpy.context.collection.objects.link(fill_obj)

    bpy.context.scene.camera = cam_obj
    return cam_obj


def render_angles(center, size):
    os.makedirs(RENDER_DIR, exist_ok=True)
    cam_obj = setup_camera_and_light(center)

    scene = bpy.context.scene
    try:
        scene.render.engine = 'BLENDER_EEVEE_NEXT'
    except TypeError:
        scene.render.engine = 'BLENDER_EEVEE'
    try:
        scene.eevee.use_gtao = True
    except AttributeError:
        pass
    scene.render.resolution_x = RENDER_RESOLUTION[0]
    scene.render.resolution_y = RENDER_RESOLUTION[1]

    distance = size * 3.0
    for name, direction in RENDER_ANGLES.items():
        cam_obj.location = center + mathutils.Vector(direction).normalized() * distance
        scene.render.filepath = os.path.join(RENDER_DIR, f"{name}.png")
        bpy.ops.render.render(write_still=True)
        print(f"Rendered {scene.render.filepath}")


def export_stl(obj, filename):
    bpy.ops.object.select_all(action='DESELECT')
    obj.select_set(True)
    bpy.context.view_layer.objects.active = obj
    path = os.path.join(EXPORT_DIR, filename)
    bpy.ops.wm.stl_export(filepath=path, export_selected_objects=True)
    print(f"Exported {path}")


# ============================================================
# STANDING-CARD HOLDER
# ============================================================

def build_stepped_comb_profile(fin_thicks, gaps, floor_hs, fin_height, back_drop_z=0.0):
    """Trace a stepped-base, N-upright-fin comb as ONE closed (y, z) loop -
    zero booleans (unchanged from v5 - see that file's own comment for
    the full reasoning)."""
    pts = [(0.0, 0.0)]
    y = 0.0
    front_floor = 0.0
    for i, ft in enumerate(fin_thicks):
        top = front_floor + fin_height
        pts.append((y, top))
        y += ft
        pts.append((y, top))
        if i < len(gaps):
            back_floor = floor_hs[i]
            pts.append((y, back_floor))
            y += gaps[i]
            pts.append((y, back_floor))
            front_floor = back_floor
        else:
            pts.append((y, back_drop_z))
    return pts, y


def build_holder():
    """Same stiletto-silhouette comb+wedge as v5 - see that file's own
    build_holder() for the full massing rationale. Only real change:
    round tenon sockets instead of rectangular, see TENON_DIAMETER's
    comment at the top of this file."""
    fin_thicks = [FIN_THICK, FIN_THICK, FIN_THICK, BACK_FIN_THICK]
    gaps = [STANDING_SLOT_GAP, STANDING_SLOT_GAP, STANDING_SLOT_GAP]

    y_fin1_end = FIN_THICK + STANDING_SLOT_GAP + FIN_THICK

    wedge_start_y = y_fin1_end
    peak_h = FLOOR_HS[1] - HOLLOW_SHELL_T

    zigzag, depth = build_stepped_comb_profile(fin_thicks, gaps, FLOOR_HS, FIN_HEIGHT,
                                                back_drop_z=peak_h)
    assert abs(depth - HOLDER_DEPTH) < 1e-6

    profile = zigzag + [(wedge_start_y, 0.0)]
    shell = _extrude_profile(profile, 'YZ', -HOLDER_W / 2.0, HOLDER_W, "card_holder")

    # Tenon sockets cut into the back fin from its BACK face, going
    # forward into the fin a few mm with overshoot for a clean cut -
    # round instead of v5's rectangular box, see TENON_DIAMETER's
    # comment at the top of this file for why.
    overshoot = 2.0
    for sign in (-1, 1):
        socket = build_cylinder(
            TENON_DIAMETER, TENON_SOCKET_DEPTH + overshoot,
            (sign * TENON_OFFSET_X,
             depth - TENON_SOCKET_DEPTH / 2.0 + overshoot / 2.0,
             TENON_Z_CENTER),
            "tenon_socket")
        apply_boolean(shell, socket, 'DIFFERENCE')

    return shell


def rotate_for_print(obj):
    """Bakes the reoriented-for-printing rotation into the exported mesh
    (see this file's own module docstring, point 2) - HOLDER_W (old X,
    70mm) becomes the vertical build axis, so every layer is an
    identical copy of the comb+wedge profile and nothing needs support
    except the two round sockets (which self-support on their own).
    Rotating -90deg around Y maps old X -> new Z (up) and leaves old Y
    (depth) as still-horizontal; old Z (comb height, symmetric-ish
    footprint) becomes the other horizontal axis. Applied AFTER all
    geometry (comb + sockets) is built, right before export, so every
    Z/Y reference throughout build_holder() stays in the same familiar
    coordinate convention as v5 until this one final step."""
    obj.rotation_euler = (0.0, math.radians(-90.0), 0.0)
    apply_transform(obj)
    return obj


# ============================================================
# MAIN
# ============================================================

def main():
    os.makedirs(EXPORT_DIR, exist_ok=True)
    clear_scene()

    holder = build_holder()
    apply_bevel(holder, FINISH_BEVEL_W)
    rotate_for_print(holder)

    vol = mesh_volume(holder)
    nm = nonmanifold_fraction(holder)
    bbox = [holder.matrix_world @ mathutils.Vector(c) for c in holder.bound_box]
    xs = [v.x for v in bbox]
    ys = [v.y for v in bbox]
    zs = [v.z for v in bbox]
    print(f"holder: volume={vol:.1f}mm3  non-manifold edge fraction={nm:.4f}")
    print(f"post-rotation bounds: X={max(xs)-min(xs):.1f}mm  Y={max(ys)-min(ys):.1f}mm  "
          f"Z={max(zs)-min(zs):.1f}mm  (Z should be ~{HOLDER_W:.1f}mm, the new build height)")
    assert vol > 0.0, "holder has zero/negative volume - a boolean likely emptied it"

    if EXPORT_STL:
        export_stl(holder, "card_holder.stl")

    if RENDER_IMAGES:
        center_pt, size = compute_scene_bounds()
        render_angles(center_pt, size)

    print("Done.")


if __name__ == "__main__":
    main()

How it went
#

The flat-on-back zero-support print worked, but the surface still needed sanding after support removal to look properly smooth — “no supports” isn’t the same as “no finishing work.”

The wordmark didn’t survive first time round: real vector text grazing the flat face didn’t have enough material behind thin letters like O and S to survive handling. First fix was a standalone glue-on plate with thicker, deeper-embedded letters (see gallery above) — glued on, it looked rough, so reprinted the whole backplate instead with the fix baked straight in.

text_plate.py
"""
Standalone "MYTHOS" text plate (Blender bpy) - a separate glue-on part,
not part of the backplate build anymore.

Why this exists: v5's wordmark was real vector text unioned straight
onto the backplate's front face (see build_logo_text in
card_stand_v5_flat_sharp_logo.py). After printing and support removal,
the O and S didn't survive - their stroke width at TEXT_SIZE=15 in
Baskerville is thin (moderate stroke contrast is part of that font's
design), and a thin stroke embossed onto a much bigger flat face is
exactly the kind of feature that's first to snap during sanding/support
cleanup. Plan: file the broken text off the existing printed backplate,
print this as its own small part, glue it on by eye afterward - so its
position doesn't need to match the backplate's coordinate system at all,
just its own footprint.

Two fixes versus v5's approach:
  1. Bolder strokes - curve_data.offset pushes the outline out on every
     edge (not just scaling the whole glyph up), so O's ring and S's
     curve both gain real wall thickness instead of getting
     proportionally thin-but-bigger.
  2. Real attachment - text is unioned onto a solid 2mm backing plate
     with a deep embed (not a thin graze against a big flat face like
     before), so the joint has actual volume behind it, and the whole
     plate is what gets glued down - no individual letter is depending
     on its own bond to survive alone.

Printed flat (plate on the bed, letters facing up) - same zero-support
logic as v5's backplate reorientation: the only raised feature all
projects the same direction, straight up.

Run:
  /Applications/Blender.app/Contents/MacOS/Blender --background --python text_plate_v1.py
"""

import bpy
import bmesh
import math
import mathutils
import os

# ============================================================
# CONFIG (all mm)
# ============================================================

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))

EXPORT_DIR = os.path.join(SCRIPT_DIR, "output_text_plate_v1")
EXPORT_STL = True

RENDER_IMAGES = True
RENDER_DIR = os.path.join(EXPORT_DIR, "renders")
RENDER_RESOLUTION = (1600, 1200)

# --- Text ---
TEXT_FONT_PATH = "/System/Library/Fonts/Supplemental/Baskerville.ttc"
TEXT_STRING = "MYTHOS"
TEXT_SIZE = 18.0          # keep at 18 - dropping to 15 makes TEXT_BOLD_OFFSET proportionally too
                          # aggressive for the glyphs and collapses the union (verified: "MY" and
                          # part of the O disappeared, non-manifold fraction spiked to 12%). Shrink
                          # the plate via PLATE_MARGIN_X instead - doesn't touch text geometry.
TEXT_BOLD_OFFSET = 0.12  # curve outline offset - pushes every stroke edge out this much, the
                          # fix for O's ring / S's curve reading too thin. Tested 0.08-0.22: past
                          # ~0.13 the offset self-intersects O/S's tight inner curves and the
                          # union silently collapses most of the letterforms (volume drops from
                          # ~3000mm3 to ~200mm3, non-manifold fraction jumps to 12%) - 0.12 is
                          # comfortably inside the safe range (visibly bolder, volume ~3100mm3,
                          # non-manifold ~5%, every letter intact - verified by render).
TEXT_RAISE_H = 1.8        # how proud of the plate's top face the letters stand
TEXT_EMBED = 1.2          # how far into the plate the text volume also extends, for a real union
                           # with the plate rather than a shallow surface graze

# --- Backing plate ---
PLATE_T = 2.0             # requested: 2mm thick backing plate
PLATE_MARGIN_X = 1.5      # margin around the measured text bounding box - trimmed from 6.0 so the
                          # plate (was 73mm) fits inside the 70mm backplate it glues onto
PLATE_MARGIN_Z = 5.0
PLATE_BEVEL_W = 0.6

# ============================================================
# GENERIC HELPERS (same conventions as card_stand_v5_flat_sharp_logo.py)
# ============================================================

def clear_scene():
    bpy.ops.object.select_all(action='SELECT')
    bpy.ops.object.delete()
    for block in list(bpy.data.meshes):
        bpy.data.meshes.remove(block)


def apply_boolean(target, cutter, operation):
    mod = target.modifiers.new("Bool", 'BOOLEAN')
    mod.object = cutter
    mod.operation = operation
    mod.solver = 'EXACT'
    bpy.context.view_layer.objects.active = target
    bpy.ops.object.modifier_apply(modifier=mod.name)
    bpy.data.objects.remove(cutter, do_unlink=True)
    return target


def union_onto(base, piece):
    return apply_boolean(base, piece, 'UNION')


def apply_transform(obj):
    bpy.ops.object.select_all(action='DESELECT')
    obj.select_set(True)
    bpy.context.view_layer.objects.active = obj
    bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)


def apply_bevel(obj, width, segments=2):
    mod = obj.modifiers.new("Bevel", 'BEVEL')
    mod.width = width
    mod.segments = segments
    mod.limit_method = 'ANGLE'
    bpy.context.view_layer.objects.active = obj
    bpy.ops.object.modifier_apply(modifier=mod.name)
    return obj


def mesh_volume(obj):
    bm = bmesh.new()
    bm.from_mesh(obj.data)
    bm.transform(obj.matrix_world)
    volume = bm.calc_volume()
    bm.free()
    return volume


def nonmanifold_fraction(obj):
    bm = bmesh.new()
    bm.from_mesh(obj.data)
    bad = sum(1 for e in bm.edges if not e.is_manifold)
    total = len(bm.edges)
    bm.free()
    return bad / total if total else 0.0


def build_box(sx, sy, sz, center, name):
    bm = bmesh.new()
    bmesh.ops.create_cube(bm, size=1.0)
    bmesh.ops.scale(bm, vec=(sx, sy, sz), verts=bm.verts)
    bmesh.ops.translate(bm, vec=center, verts=bm.verts)
    mesh = bpy.data.meshes.new(name)
    bm.to_mesh(mesh)
    bm.free()
    obj = bpy.data.objects.new(name, mesh)
    bpy.context.collection.objects.link(obj)
    return obj


def export_stl(obj, filename):
    bpy.ops.object.select_all(action='DESELECT')
    obj.select_set(True)
    bpy.context.view_layer.objects.active = obj
    path = os.path.join(EXPORT_DIR, filename)
    bpy.ops.wm.stl_export(filepath=path, export_selected_objects=True)
    print(f"Exported {path}")


def compute_scene_bounds():
    xs, ys, zs = [], [], []
    for obj in bpy.context.scene.objects:
        if obj.type != 'MESH':
            continue
        for corner in obj.bound_box:
            world_corner = obj.matrix_world @ mathutils.Vector(corner)
            xs.append(world_corner.x)
            ys.append(world_corner.y)
            zs.append(world_corner.z)
    if not xs:
        return mathutils.Vector((0.0, 0.0, 0.0)), 10.0
    center = mathutils.Vector((
        (min(xs) + max(xs)) / 2,
        (min(ys) + max(ys)) / 2,
        (min(zs) + max(zs)) / 2,
    ))
    size = max(max(xs) - min(xs), max(ys) - min(ys), max(zs) - min(zs))
    return center, size


def setup_camera_and_light(center, plate_w, plate_h):
    # Straight-on front view (looking down -Y), not an angled iso shot -
    # more useful for judging a flat plaque than a 3D perspective would be.
    # ortho_scale fills the LARGER of the sensor's two axes; with a
    # landscape render, that's the horizontal one, so scale to whichever
    # of width or (height * render aspect) is bigger, plus margin.
    aspect = RENDER_RESOLUTION[0] / RENDER_RESOLUTION[1]
    scale = max(plate_w, plate_h * aspect) * 1.25

    cam_data = bpy.data.cameras.new("RenderCam")
    cam_data.type = 'ORTHO'
    cam_data.ortho_scale = scale
    cam = bpy.data.objects.new("RenderCam", cam_data)
    bpy.context.collection.objects.link(cam)
    cam.location = center + mathutils.Vector((0.0, -scale, 0.0))
    cam.rotation_euler = (math.radians(90.0), 0.0, 0.0)
    bpy.context.scene.camera = cam

    light_data = bpy.data.lights.new("RenderLight", type='SUN')
    light_data.energy = 3.0
    light = bpy.data.objects.new("RenderLight", light_data)
    bpy.context.collection.objects.link(light)
    light.location = center + mathutils.Vector((scale * 0.3, -scale * 1.5, scale * 0.6))
    direction = center - light.location
    light.rotation_euler = direction.to_track_quat('-Z', 'Y').to_euler()


def render_to(path):
    scene = bpy.context.scene
    scene.render.resolution_x, scene.render.resolution_y = RENDER_RESOLUTION
    scene.render.filepath = path
    scene.render.image_settings.file_format = 'PNG'
    bpy.ops.render.render(write_still=True)
    print(f"Rendered {path}")


# ============================================================
# BUILD
# ============================================================

def build_text_mesh():
    """Real vector text, bolded via curve outline offset (not just a
    bigger font size) so O's ring and S's curve both gain real stroke
    width. Returns the converted mesh object, still at its own local
    origin (not yet measured/centered)."""
    font = bpy.data.fonts.load(TEXT_FONT_PATH)
    curve_data = bpy.data.curves.new("text_curve", type='FONT')
    curve_data.body = TEXT_STRING
    curve_data.font = font
    curve_data.size = TEXT_SIZE
    curve_data.align_x = 'CENTER'
    curve_data.align_y = 'CENTER'
    curve_data.offset = TEXT_BOLD_OFFSET
    curve_data.extrude = (TEXT_RAISE_H + TEXT_EMBED) / 2.0
    obj = bpy.data.objects.new("text", curve_data)
    bpy.context.collection.objects.link(obj)

    bpy.ops.object.select_all(action='DESELECT')
    obj.select_set(True)
    bpy.context.view_layer.objects.active = obj
    bpy.ops.object.convert(target='MESH')
    return obj


def build_plate():
    text_obj = build_text_mesh()

    # Measure the actual glyph ink extent (align_y='TOP' trusts the
    # font's ascender line, not the real cap-height - see v5's
    # build_logo_text for the same lesson) so the plate is sized to
    # what's really there, not a guessed font metric.
    xs = [v.co.x for v in text_obj.data.vertices]
    ys_ink = [v.co.y for v in text_obj.data.vertices]
    text_w = max(xs) - min(xs)
    text_h = max(ys_ink) - min(ys_ink)

    plate_w = text_w + 2 * PLATE_MARGIN_X
    plate_h = text_h + 2 * PLATE_MARGIN_Z

    plate = build_box(plate_w, PLATE_T, plate_h, (0.0, PLATE_T / 2.0, 0.0), "text_plate")
    apply_bevel(plate, PLATE_BEVEL_W)

    # Text curve extrudes along its own local Z, which after this
    # rotation becomes world Y (front-to-back through the plate) -
    # same convention as v5's build_logo_text.
    text_obj.rotation_euler = (math.radians(90.0), 0.0, 0.0)
    text_obj.location = (0.0, TEXT_RAISE_H - (TEXT_RAISE_H + TEXT_EMBED) / 2.0, 0.0)
    apply_transform(text_obj)
    union_onto(plate, text_obj)

    return plate, plate_w, plate_h


# ============================================================
# MAIN
# ============================================================

def main():
    os.makedirs(EXPORT_DIR, exist_ok=True)
    clear_scene()

    plate, plate_w, plate_h = build_plate()

    vol = mesh_volume(plate)
    nm = nonmanifold_fraction(plate)
    print(f"text_plate: {plate_w:.1f}x{PLATE_T:.1f}x{plate_h:.1f}mm  "
          f"volume={vol:.1f}mm3  non-manifold edge fraction={nm:.4f}")
    assert vol > 0.0, "text_plate has zero/negative volume - a boolean likely emptied it"

    if EXPORT_STL:
        export_stl(plate, "text_plate.stl")

    if RENDER_IMAGES:
        os.makedirs(RENDER_DIR, exist_ok=True)
        center, _ = compute_scene_bounds()
        setup_camera_and_light(center, plate_w, plate_h)
        render_to(os.path.join(RENDER_DIR, "text_plate_front.png"))

    print("Done.")


if __name__ == "__main__":
    main()

Downloads
#

The Mythos logo makes these specific to that store, but figured I’d host the files anyway in case the owner wants to reprint or tweak it themselves at some point:


Cards stand freely in the slots, no glue needed there — just the backplate-to-holder tenon joint. Handed over, hopefully useful for the counter.