singe/util/textModel.py

176 lines
9.7 KiB
Python

# Builds assets/SingeText.obj (+ .mtl): the Singe logotype from SingeText.jpeg as solid letters,
# extruded with a chamfer on both faces, standing upright in the XY plane with depth along Z,
# resting on Y = 0 and centred on X = 0. Convert with util/objToGlb.py for Singe.
#
# --print makes the printable variant: the letters sit on a backing plate that joins them into one
# piece, with the back faces flat on the plate and the chamfer on the front only. It is split into
# three colour bodies (letter faces, letter walls, plate); --stl writes one STL per body in
# millimetres, --width mm wide, and --check voxelises the result and counts the pieces.
#
# Usage: python3 util/textModel.py assets/SingeText.jpeg assets/SingeText.obj [--depth 15] [--chamfer 5]
# python3 util/textModel.py assets/SingeText.jpeg assets/SingeTextPrint.obj --print --stl assets --width 150 --check
import argparse
import os
import numpy as np
from PIL import Image, ImageFilter
from scipy import ndimage
from objWriter import ObjWriterT, writeMtl, pieceCount, reportPieces, DRAGON_MATERIALS
from outline import traceBoundary, simplifyClean, signedArea, offsetRing, earClip
FACE = 6.0 # thickness of the coloured face layer on printable letters, image pixels
PLATE = 16.0 # thickness of a backing plate, image pixels
PLATE_MARGIN = 12.0 # how far a plate reaches beyond what it carries, image pixels
PLATE_BRIDGE = 40 # closing radius (at UPSCALE) that joins separate letters into one plate outline
UPSCALE = 2 # the logo is traced at twice its size for smoother curves
BODIES = {'face': 'dark', 'walls': 'frame', 'plate': 'plate'}
def identity(p):
return p
def plateMask(mask, margin, bridge, upscale=1):
# A plate outline around a mask: gaps bridged, reaching margin beyond it, one island, spurs removed.
radius = bridge
while True:
plate = ndimage.binary_dilation(mask, iterations=radius)
plate = ndimage.binary_erosion(plate, iterations=int(radius - margin * upscale))
plate = ndimage.binary_fill_holes(plate)
plate = ndimage.binary_opening(plate, iterations=3 * upscale)
if ndimage.label(plate)[1] == 1:
return plate
radius += 10
class LogoT:
# The traced logotype: ink mask, one outline ring per letter, and the routines that extrude them.
def __init__(self, src, scale=0.01, depth=15.0, chamfer=5.0, tolerance=1.5):
self.scale = scale
self.depth = depth
self.chamfer = chamfer
grey = Image.open(src).convert('L')
big = grey.resize((grey.width * UPSCALE, grey.height * UPSCALE), Image.LANCZOS)
big = big.filter(ImageFilter.GaussianBlur(3.5 / (4 / UPSCALE)))
self.ink = np.asarray(big) < 128
ys, xs = np.nonzero(self.ink)
self.setFootprint(xs.min() / UPSCALE, xs.max() / UPSCALE, ys.max() / UPSCALE)
labels, count = ndimage.label(self.ink)
self.holes = 0
self.letters = []
for index in range(1, count + 1):
component = labels == index
if component.sum() < 50 * UPSCALE * UPSCALE:
continue
filled = ndimage.binary_fill_holes(component)
self.holes += int((filled & ~component).sum() > 0)
self.letters.append(self.ringOf(filled, tolerance))
self.letters.sort(key=lambda ring: ring[:, 0].min())
self.writer = None
def setFootprint(self, x0, x1, y1):
# Model space: centred on X between x0 and x1, resting on Y = 0 at image row y1.
self.x0 = x0
self.x1 = x1
self.y1 = y1
def toModel(self, x, y, z):
return np.array([(x - (self.x0 + self.x1) / 2) * self.scale, (self.y1 - y) * self.scale, z * self.scale])
def ringOf(self, mask, tolerance):
# Traced at UPSCALE, simplified, in image pixels, counter-clockwise as seen from +Z in model space.
ring = simplifyClean(traceBoundary(mask), tolerance * UPSCALE) / UPSCALE
xy = np.array([self.toModel(x, y, 0.0)[:2] for x, y in ring])
return ring[::-1] if signedArea(xy) < 0 else ring
def prism(self, name, ring, levels, capMaterial, transform=identity):
# levels: (ring, z, material of the walls rising from it) from back to front; caps close the ends.
rings = [[self.writer.vertex(transform(self.toModel(x, y, z))) for x, y in poly] for poly, z, material in levels]
self.writer.object(name)
self.writer.material(capMaterial)
ringCcw = signedArea(ring) > 0
for a, b, c in earClip(ring):
tri = [a, b, c] if ringCcw else [c, b, a]
self.writer.face([rings[0][i] for i in tri[::-1]])
self.writer.face([rings[-1][i] for i in tri])
n = len(ring)
for level in range(len(rings) - 1):
self.writer.material(levels[level][2])
a = rings[level]
b = rings[level + 1]
for i in range(n):
j = (i + 1) % n
self.writer.face([a[i], a[j], b[j], b[i]])
def buildLetters(self, writer, printable, zBase, transform=identity):
# Display: letters from zBase up, chamfered both sides. Printable: flat-backed letters from
# zBase up, walls in one body and the chamfered face in another.
self.writer = writer
depth = self.depth
chamfer = self.chamfer
for number, ring in enumerate(self.letters):
inset = offsetRing(ring, -chamfer)
name = 'letter%02d' % (number + 1)
if printable:
self.prism(name + 'Walls', ring, [(ring, zBase, BODIES['walls']), (ring, zBase + 2 * depth - FACE, BODIES['walls'])], BODIES['walls'], transform)
self.prism(name + 'Face', ring, [(ring, zBase + 2 * depth - FACE, BODIES['face']), (ring, zBase + 2 * depth - chamfer, BODIES['face']), (inset, zBase + 2 * depth, BODIES['face'])], BODIES['face'], transform)
else:
levels = [(inset, zBase, 'logo'), (ring, zBase + chamfer, 'logo'), (ring, zBase + 2 * depth - chamfer, 'logo'), (inset, zBase + 2 * depth, 'logo')]
self.prism(name, ring, levels, 'logo', transform)
def buildPlate(self, writer, mask, zTop, transform=identity):
# A backing plate under a mask given at UPSCALE, from zTop - PLATE up to zTop.
self.buildPlateRing(writer, self.ringOf(mask, 2.0), zTop, transform)
def buildPlateRing(self, writer, ring, zTop, transform=identity):
# The plate from an outline ring in this logo's image pixels.
self.writer = writer
self.prism('plate', ring, [(ring, zTop - PLATE, BODIES['plate']), (ring, zTop, BODIES['plate'])], BODIES['plate'], transform)
def writeOutputs(writer, out, header, materials, bodies=None, stlDir=None, widthMm=None, modelWidth=None, check=False, voxel=0.01):
mtlName = os.path.splitext(os.path.basename(out))[0] + '.mtl'
writer.write(out, mtlName, header)
writeMtl(os.path.join(os.path.dirname(out), mtlName), materials, 'Materials for %s' % os.path.basename(out))
if stlDir:
stem = os.path.splitext(os.path.basename(out))[0]
unitScale = widthMm / modelWidth
for body, material in bodies.items():
path = os.path.join(stlDir, '%s-%s.stl' % (stem, body))
print('%s: %d triangles' % (path, writer.writeStl(path, {material}, unitScale, '%s %s' % (stem, body))))
if check:
reportPieces(*pieceCount(writer, voxel))
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('src')
parser.add_argument('out')
parser.add_argument('--scale', type=float, default=0.01, help='model units per image pixel')
parser.add_argument('--depth', type=float, default=15.0, help='half thickness of the letters in image pixels')
parser.add_argument('--chamfer', type=float, default=5.0, help='edge chamfer in image pixels')
parser.add_argument('--tolerance', type=float, default=1.5, help='outline simplification in image pixels')
parser.add_argument('--print', dest='printable', action='store_true', help='one-piece printable variant on a backing plate')
parser.add_argument('--stl', default=None, help='directory to write one STL per colour body into')
parser.add_argument('--width', type=float, default=150.0, help='width of the logo in the STL files, millimetres')
parser.add_argument('--check', action='store_true', help='voxelise the model and count separate pieces')
args = parser.parse_args()
logo = LogoT(args.src, args.scale, args.depth, args.chamfer, args.tolerance)
writer = ObjWriterT()
if args.printable:
plate = plateMask(logo.ink, PLATE_MARGIN, PLATE_BRIDGE, UPSCALE)
# The plate is the footprint now: it, not the letters, rests on Y = 0.
ys, xs = np.nonzero(plate)
logo.setFootprint(xs.min() / UPSCALE, xs.max() / UPSCALE, ys.max() / UPSCALE)
logo.buildPlate(writer, plate, -args.depth)
logo.buildLetters(writer, True, -args.depth)
materials = {BODIES[body]: DRAGON_MATERIALS[BODIES[body]] for body in BODIES}
bodies = BODIES
else:
logo.buildLetters(writer, False, -args.depth)
materials = {'logo': DRAGON_MATERIALS['dark']}
bodies = {'logo': 'logo'}
variant = 'printable ' if args.printable else ''
header = ['Singe logotype, %sextruded from SingeText.jpeg by util/textModel.py' % variant,
'Units: %g per image pixel; Y up, front faces toward +Z, resting on Y = 0, centred on X = 0' % args.scale]
print('%s: %d letters, %d vertices, %d faces%s' % (args.out, len(logo.letters), len(writer.vertices), writer.faceCount, ', %d with holes (filled)' % logo.holes if logo.holes else ''))
writeOutputs(writer, args.out, header, materials, bodies, args.stl, args.width, (logo.x1 - logo.x0) * args.scale, args.check, args.scale)