# engrave.py — cut personalised text into a finished vase STL. # # blender -b -P engrave.py -- coral "Emma" # blender -b -P engrave.py -- coral "Est. 2019" --where=inside --font=cambriai.ttf # # Why the base and not the wall: text on a vertical wall is an overhang on every # letter's underside and gets staircased by the 0.2 mm layers. The base is the # first layer — a pure XY operation printed flat against the bed, so it is the # crispest text an FDM printer can make, and it costs nothing in print time. # # Everything below is in MODEL units, like the rest of the project; the real-world # figures are derived from --height (what slice.py will scale to). Serif hairlines # must land at or above one extrusion width (0.45 mm) or the slicer drops them — # Gabriola needs a 15 mm cap height to clear that, which is why it is the default. import bpy, bmesh, sys, os, math, re from mathutils import Matrix argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] opts = {a.split("=")[0].lstrip("-"): a.split("=", 1)[1] for a in argv if a.startswith("--") and "=" in a} pos = [a for a in argv if not a.startswith("--")] NAME, TEXT = pos[0], pos[1] HERE = os.path.dirname(os.path.abspath(__file__)) OUT = os.path.join(HERE, "out") FONTDIR = r"C:\Windows\Fonts" FONT = opts.get("font", "Gabriola.ttf") WHERE = opts.get("where", "base") # base | inside HEIGHT = float(opts.get("height", 225)) # the print height slice.py will use CAP_MM = float(opts.get("cap", 15)) # cap height, real mm DEPTH_MM= float(opts.get("depth", 0.8)) # engraving depth, real mm MAX_MM = float(opts.get("maxwidth", 88)) # widest the mark may be, real mm bpy.ops.wm.read_factory_settings(use_empty=True) bpy.ops.wm.stl_import(filepath=os.path.join(OUT, f"{NAME}.stl")) vase = bpy.context.selected_objects[0] vase.name = "vase" zlo, zhi = min(v.co.z for v in vase.data.vertices), max(v.co.z for v in vase.data.vertices) SCALE = HEIGHT / (zhi - zlo) # model units -> real mm cap = CAP_MM / SCALE depth = DEPTH_MM / SCALE maxw = MAX_MM / SCALE # ---------- the text solid ---------- def text_object(body, size): bpy.ops.object.text_add() t = bpy.context.object t.data.body = body t.data.font = bpy.data.fonts.load(os.path.join(FONTDIR, FONT)) t.data.size = size t.data.align_x, t.data.align_y = 'CENTER', 'CENTER' t.data.space_line = 0.85 t.data.extrude = 4 * depth # thick block; only `depth` of it ends up inside t.data.resolution_u = 6 # smooth curves -> smooth engraved edges return t # Blender's `size` is the em, not the cap height — calibrate against a real "H". probe = text_object("H", 1.0) bpy.ops.object.convert(target='MESH') ys = [v.co.y for v in probe.data.vertices] cap_per_em = max(ys) - min(ys) bpy.data.objects.remove(probe) lines = TEXT.split("|") # "Klara|& Jonas" -> two lines txt = text_object("\n".join(lines), cap / cap_per_em) bpy.ops.object.convert(target='MESH') # Blender's text-to-mesh leaves a duplicate vertex at every contour seam, which # reads as non-manifold and makes the MANIFOLD solver refuse the cut outright. _bm = bmesh.new(); _bm.from_mesh(txt.data) bmesh.ops.remove_doubles(_bm, verts=_bm.verts, dist=1e-5) _left = sum(1 for e in _bm.edges if not e.is_manifold) _bm.to_mesh(txt.data); _bm.free() if _left: raise SystemExit(f"text mesh still has {_left} non-manifold edges — try a plainer font") bb = [txt.matrix_world @ Matrix.Identity(4) @ v.co for v in txt.data.vertices] w = max(p.x for p in bb) - min(p.x for p in bb) if w > maxw: # never let it run off the base txt.data.transform(Matrix.Scale(maxw / w, 4)) print(f"NOTE shrunk to fit: cap {CAP_MM * maxw / w:.1f} mm real " f"({'hairlines may drop below one extrusion width' if CAP_MM * maxw / w < 15 else 'ok'})") w = maxw # read correctly when the vase is turned over: 180 deg about Y, not a mirror # (a mirror would invert the normals; a rotation is rigid) txt.data.transform(Matrix.Rotation(math.pi, 4, 'Y')) top = max(v.co.z for v in txt.data.vertices) if WHERE == "base": # cut up into the underside from z = zlo txt.data.transform(Matrix.Translation((0, 0, zlo + depth - top))) else: # cut down into the interior floor floor = zlo + 2.5 # FLOOR in gen_vases.py txt.data.transform(Matrix.Rotation(math.pi, 4, 'Y')) # face up again; reads from above lo = min(v.co.z for v in txt.data.vertices) txt.data.transform(Matrix.Translation((0, 0, floor - depth - lo))) # ---------- cut ---------- bpy.context.view_layer.objects.active = vase before = len(vase.data.polygons) m = vase.modifiers.new("engrave", 'BOOLEAN') m.operation, m.object, m.solver = 'DIFFERENCE', txt, 'MANIFOLD' bpy.ops.object.modifier_apply(modifier=m.name) bpy.data.objects.remove(txt) if len(vase.data.polygons) == before: # the modifier warns but still "succeeds" raise SystemExit("boolean did nothing — the cut never landed, refusing to write an STL") # No voxel remesh here on purpose: 0.45 mm voxels would erase a 0.8 mm engraving. # MANIFOLD leaves a clean solid; merge only at STL float32 precision. bm = bmesh.new(); bm.from_mesh(vase.data) bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-4) nonman = sum(1 for e in bm.edges if not e.is_manifold) bm.to_mesh(vase.data); bm.free() slug = re.sub(r'[^a-z0-9]+', '-', TEXT.lower()).strip('-') tag = "" if WHERE == "base" else f"-{WHERE}" dest = os.path.join(OUT, f"{NAME}_{slug}{tag}.stl") vase.select_set(True); bpy.context.view_layer.objects.active = vase bpy.ops.wm.stl_export(filepath=dest, export_selected_objects=True) print(f"ENGRAVED {os.path.basename(dest)}") print(f" text {TEXT!r} {FONT} on the {WHERE}") print(f" real size cap {CAP_MM:.0f} mm, {w * SCALE:.0f} mm wide, {DEPTH_MM:.1f} mm deep") print(f" nonmanifold edges {nonman} faces {len(vase.data.polygons)}")