singe/util/dragonModel.py

320 lines
17 KiB
Python

# Builds assets/DragonModel.obj (+ .mtl): a solid, three-dimensional dragon whose side view is exactly
# the tangram picture. The tiles of Dragon.jpeg are grouped into body parts; each part gets its own
# thickness, with the tiles as chamfered slabs on both faces and a bulging faceted core between them.
# Legs and horns are mirrored to both sides, and the wings are swept out from the shoulders.
#
# --print makes the printable variant: every tile grows out to meet its neighbours (no gaps), wings,
# horns and crest become thick plates with cores of their own, and the cores are a fourth material
# ("frame") that shows as grout in the V-grooves between tiles and on every side wall. --stl writes
# one binary STL per material for a multi-colour printer, scaled so the dragon is --width mm long;
# --check voxelises the result and reports how many separate pieces it forms.
#
# --relief (with --print) keeps only the top half, lying flat: every part's core runs down to Z = 0
# so the dragon can sit on a plate (see util/plaqueModel.py).
#
# Usage: python3 util/dragonModel.py pieces.json assets/DragonModel.obj [--scale 0.01]
# python3 util/dragonModel.py pieces.json assets/DragonPrint.obj --print --stl assets --width 150 --check
# pieces.json comes from: python3 util/traceDragon.py assets/Dragon.jpeg assets/Dragon.svg --pieces pieces.json
import argparse
import json
import os
import numpy as np
from PIL import Image, ImageDraw
from scipy import ndimage
from objWriter import ObjWriterT, writeMtl, pieceCount, reportPieces, DRAGON_MATERIALS
from outline import traceBoundary, simplifyClean, signedArea, offsetRing, earClip
SLAB = 4.0 # thickness of a tile slab, image pixels
CHAMFER = 3.0 # chamfer on the visible tile edges
SEAM = 4 # how far a part outline grows beyond its tiles, image pixels
CLOSE = 14 # closing radius that bridges the gaps between a part's tiles
BULGE = 0.15 # how far the core swells outward at its mid-plane, as a fraction of depth
OUTLINE_TOLERANCE = 2.5 # polygon simplification for part outlines, image pixels
CELL_TOLERANCE = 1.0 # polygon simplification for grown tiles in the printable variant
CELL_GAP = 0.6 # inset of each grown tile so neighbouring colour bodies never overlap
PLATE_DEPTH = 7.0 # half thickness of printable wings, horns and crest
BODY_DEPTH = 55.0
MATERIAL_ORDER = ('dark', 'champagne', 'glass', 'frame', 'plate')
def identity(p):
return p
class DragonT:
# Everything needed to build the dragon from a pieces.json: tile rasters, part outlines, and the
# mesh routines. build() emits the parts into a writer; footprint() is the outline of the parts.
def __init__(self, data, scale=0.01, printable=False, relief=False):
self.pieces = data['pieces']
self.x0, self.y0, self.x1, self.y1 = data['bounds']
self.width = self.x1 - self.x0
self.imageW = data['width']
self.imageH = data['height']
self.scale = scale
self.printable = printable
self.relief = relief
tileImage = Image.new('I', (self.imageW, self.imageH), -1)
draw = ImageDraw.Draw(tileImage)
for index, piece in enumerate(self.pieces):
draw.polygon([tuple(p) for p in piece['points']], fill=index)
self.tileIndex = np.asarray(tileImage).astype(int)
self.nearestY, self.nearestX = ndimage.distance_transform_edt(self.tileIndex < 0, return_indices=True)[1]
self.centroids = np.array([np.mean(piece['points'], axis=0) for piece in self.pieces])
self.tileDistance = {}
self.masks = {}
self.writer = None
# ---- Tiles and outlines ----
def tilesNear(self, points):
# Pick tiles by the nearest centroid to each anchor, so the selection survives re-tracing.
found = []
for p in points:
index = int(np.argmin(np.hypot(*(self.centroids - np.array(p, dtype=float)).T)))
if index not in found:
found.append(index)
return found
def materialNear(self, x, y):
xi = min(self.imageW - 1, max(0, int(round(x))))
yi = min(self.imageH - 1, max(0, int(round(y))))
return self.pieces[self.tileIndex[self.nearestY[yi, xi], self.nearestX[yi, xi]]]['material']
def toModel(self, x, y, z):
return np.array([(x - (self.x0 + self.width / 2)) * self.scale, (self.y1 - y) * self.scale, z * self.scale])
def modelCcw(self, ring):
# Orders a ring counter-clockwise as seen from +Z in model space (image y points down, so this
# is clockwise on the picture).
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 partMask(self, tiles):
# Close the gaps between the tiles (which can be wider than the seams where corners meet)
# without growing the outline by more than SEAM, and make sure the part is one island.
key = tuple(tiles)
if key in self.masks:
return self.masks[key]
mask = np.isin(self.tileIndex, tiles)
radius = CLOSE
while True:
closed = ndimage.binary_dilation(mask, iterations=radius)
closed = ndimage.binary_erosion(closed, iterations=radius - SEAM)
closed = ndimage.binary_fill_holes(closed)
if ndimage.label(closed)[1] == 1 or radius > 60:
self.masks[key] = closed
return closed
radius += 8
def tileCells(self, tiles, mask):
# Grow every tile of a part to the bisectors between it and its neighbours, filling the whole
# part outline, then pull each cell in a touch so the colour bodies stay disjoint.
for index in tiles:
if index not in self.tileDistance:
self.tileDistance[index] = ndimage.distance_transform_edt(self.tileIndex != index)
owner = np.argmin(np.stack([self.tileDistance[index] for index in tiles]), axis=0)
cells = []
for k, index in enumerate(tiles):
cell = mask & (owner == k)
labels, count = ndimage.label(cell)
if count > 1:
sizes = ndimage.sum(cell, labels, range(1, count + 1))
cell = labels == (1 + int(np.argmax(sizes)))
ring = simplifyClean(traceBoundary(cell), CELL_TOLERANCE)
cells.append((index, offsetRing(self.modelCcw(ring), -CELL_GAP)))
return cells
def insetRing(self, ring, chamfer):
# Shrink the chamfer until the inset outline keeps every edge pointing the way it did.
n = len(ring)
while chamfer > 0.25:
inner = offsetRing(ring, -chamfer)
good = np.sign(signedArea(inner)) == np.sign(signedArea(ring))
for i in range(n):
if not good:
break
good = (ring[(i + 1) % n] - ring[i]) @ (inner[(i + 1) % n] - inner[i]) > 0
if good:
return inner, chamfer
chamfer = chamfer * 0.5
return ring.copy(), 0.0
# ---- Mesh pieces ----
def emitFaces(self, name, faces):
# faces: list of (material, indices); grouped by material under one object.
self.writer.object(name)
byMaterial = {}
for material, indices in faces:
byMaterial.setdefault(material, []).append(indices)
for material, items in byMaterial.items():
self.writer.material(material)
for indices in items:
self.writer.face(indices)
def rings(self, levels, transform):
return [([self.writer.vertex(transform(self.toModel(x, y, z))) for x, y in poly], poly) for poly, z in levels]
@staticmethod
def walls(rings, material=None, materialAt=None):
# Quads between successive rings of equal length, ordered by increasing Z; outward for CCW rings.
faces = []
n = len(rings[0][0])
for level in range(len(rings) - 1):
a, ringA = rings[level]
b, ringB = rings[level + 1]
for i in range(n):
j = (i + 1) % n
m = material if materialAt is None else materialAt((ringA[i] + ringA[j]) / 2)
faces.append((m, [a[i], a[j], b[j], b[i]]))
return faces
@staticmethod
def caps(ring, backIndices, frontIndices, materialAt):
# Triangulated end faces: the back one faces -Z, the front one +Z. earClip returns every
# triangle counter-clockwise in image space; match the ring's own turn.
faces = []
ringCcw = signedArea(ring) > 0
for a, b, c in earClip(ring):
tri = [a, b, c] if ringCcw else [c, b, a]
material = materialAt(ring[[a, b, c]].mean(axis=0))
faces.append((material, [backIndices[i] for i in tri[::-1]]))
faces.append((material, [frontIndices[i] for i in tri]))
return faces
def slab(self, outline, material, zFront, zBack, chamferFront, chamferBack, transform=identity):
# A tile as a slab between two Z planes, chamfered on whichever faces are visible.
ring = self.modelCcw(np.array(outline, dtype=float))
levels = []
if chamferBack > 0:
inner, chamferBack = self.insetRing(ring, chamferBack)
levels.append((inner, zBack))
levels.append((ring, zBack + chamferBack))
else:
levels.append((ring, zBack))
if chamferFront > 0:
inner, chamferFront = self.insetRing(ring, chamferFront)
levels.append((ring, zFront - chamferFront))
levels.append((inner, zFront))
else:
levels.append((ring, zFront))
rings = self.rings(levels, transform)
return self.caps(ring, rings[0][0], rings[-1][0], lambda p: material) + self.walls(rings, material)
def core(self, ring, zCentre, depth, transform=identity):
# The volume between the two tile faces of a part: the part outline, widest at its mid-plane.
# In relief the core is a plinth from Z = 0 up to the top tiles instead.
inner = depth - SLAB
if self.relief:
levels = [(ring, 0.0), (ring, zCentre + inner)]
else:
levels = [(ring, zCentre - inner), (offsetRing(ring, depth * BULGE), zCentre), (ring, zCentre + inner)]
rings = self.rings(levels, transform)
materialAt = (lambda p: 'frame') if self.printable else (lambda p: self.materialNear(p[0], p[1]))
return self.walls(rings, materialAt=materialAt) + self.caps(ring, rings[0][0], rings[-1][0], materialAt)
def part(self, name, tiles, zCentre, depth, transform=identity):
mask = self.partMask(tiles)
ring = self.modelCcw(simplifyClean(traceBoundary(mask), OUTLINE_TOLERANCE))
faces = self.core(ring, zCentre, depth, transform)
outlines = self.tileCells(tiles, mask) if self.printable else [(index, self.pieces[index]['points']) for index in tiles]
for index, outline in outlines:
material = self.pieces[index]['material']
faces += self.slab(outline, material, zCentre + depth, zCentre + depth - SLAB, CHAMFER, 0.0, transform)
if not self.relief:
faces += self.slab(outline, material, zCentre - depth + SLAB, zCentre - depth, 0.0, CHAMFER, transform)
self.emitFaces(name, faces)
def plate(self, name, tiles, zCentre, thickness, transform=identity):
# A free-standing tile group visible from both sides (wings, horns, crest): thin chamfered
# slabs in the display model, a proper cored part in the printable one.
if self.printable:
self.part(name, tiles, zCentre, PLATE_DEPTH, transform)
return
faces = []
chamfer = min(CHAMFER, thickness / 3.0)
for index in tiles:
piece = self.pieces[index]
faces += self.slab(piece['points'], piece['material'], zCentre + thickness / 2, zCentre - thickness / 2, chamfer, chamfer, transform)
self.emitFaces(name, faces)
# ---- The dragon: tile groups by anchor point, half-depths in image pixels ----
def parts(self):
# (name, tiles, zCentre, depth, plate?) for every part; relief keeps only the +Z side.
sides = ((1.0, 'R'),) if self.relief else ((-1.0, 'L'), (1.0, 'R'))
found = [('head', self.tilesNear([(800, 130), (874, 171), (968, 182), (856, 157), (838, 243), (917, 247)]), 0.0, 42.0, False),
('neck', self.tilesNear([(689, 232), (767, 233), (768, 313), (749, 383), (848, 408)]), 0.0, 36.0, False),
('body', self.tilesNear([(766, 509), (566, 492), (664, 549), (905, 511), (859, 542), (786, 638), (881, 618)]), 0.0, BODY_DEPTH, False),
('tail', self.tilesNear([(577, 730), (494, 705), (365, 713), (260, 647), (225, 558), (291, 477)]), 0.0, 26.0, False)]
for side, label in sides:
found.append(('hindLeg' + label, self.tilesNear([(934, 702), (885, 722), (1001, 727)]), side * (BODY_DEPTH - 26.0), 26.0, False))
found.append(('foreLeg' + label, self.tilesNear([(406, 589), (479, 578)]), side * 27.0, 22.0, False))
found.append(('horn' + label, self.tilesNear([(657, 117)]), side * 26.0, 10.0, True))
found.append(('crest', self.tilesNear([(711, 80)]), 0.0, 8.0, True))
for side, label in sides:
found.append(('wing' + label, self.tilesNear([(450, 246), (396, 301), (574, 398)]), side * 46.0, 6.0, True))
return found
def build(self, writer, transform=identity):
self.writer = writer
wingRoot = self.toModel(690, 462, 0.0)
wingAngle = np.radians(25.0)
for name, tiles, zCentre, depth, isPlate in self.parts():
shape = transform
if name.startswith('wing') and not self.relief:
# Hinged at the shoulder and swept outward so the two wings do not coincide.
side = -1.0 if name.endswith('L') else 1.0
cosA = np.cos(wingAngle * side)
sinA = np.sin(wingAngle * side)
def shape(p, cosA=cosA, sinA=sinA):
q = p - wingRoot
return transform(np.array([q[0] * cosA + q[2] * sinA, q[1], -q[0] * sinA + q[2] * cosA]) + wingRoot)
if isPlate:
self.plate(name, tiles, zCentre, depth, shape)
else:
self.part(name, tiles, zCentre, depth, shape)
def footprint(self):
# Union of the part outlines, in image pixels.
mask = np.zeros((self.imageH, self.imageW), dtype=bool)
for name, tiles, zCentre, depth, isPlate in self.parts():
mask |= self.partMask(tiles)
return mask
def writeOutputs(writer, out, header, stlDir=None, widthMm=None, modelWidth=None, check=False, voxel=0.01):
# OBJ + MTL, optional per-material STLs (scaled so the model spans widthMm), optional piece count.
mtlName = os.path.splitext(os.path.basename(out))[0] + '.mtl'
writer.write(out, mtlName, header)
used = [m for m in MATERIAL_ORDER if any(f[1] == m for f in writer.faces)]
writeMtl(os.path.join(os.path.dirname(out), mtlName), {m: DRAGON_MATERIALS[m] for m in used}, 'Materials for %s' % os.path.basename(out))
print('%s: %d vertices, %d faces, materials %s' % (out, len(writer.vertices), writer.faceCount, ', '.join(used)))
if stlDir:
stem = os.path.splitext(os.path.basename(out))[0]
unitScale = widthMm / modelWidth
for material in used:
path = os.path.join(stlDir, '%s-%s.stl' % (stem, material))
print('%s: %d triangles' % (path, writer.writeStl(path, {material}, unitScale, '%s %s' % (stem, material))))
if check:
reportPieces(*pieceCount(writer, voxel))
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('pieces')
parser.add_argument('out')
parser.add_argument('--scale', type=float, default=0.01, help='model units per image pixel')
parser.add_argument('--print', dest='printable', action='store_true', help='gapless, four-material printable variant')
parser.add_argument('--relief', action='store_true', help='with --print: top half only, flat underneath')
parser.add_argument('--stl', default=None, help='directory to write one STL per material into')
parser.add_argument('--width', type=float, default=150.0, help='length of the dragon in the STL files, millimetres')
parser.add_argument('--check', action='store_true', help='voxelise the model and count separate pieces')
args = parser.parse_args()
data = json.load(open(args.pieces))
dragon = DragonT(data, args.scale, args.printable, args.relief and args.printable)
writer = ObjWriterT()
dragon.build(writer)
variant = ('printable relief ' if dragon.relief else 'printable ') if args.printable else ''
header = ['Singe dragon, %ssolid model built by util/dragonModel.py from the tiles of Dragon.jpeg' % variant,
'Units: %g per image pixel; Y up, faces right (+X), resting on Y = 0, symmetric about Z = 0' % args.scale]
writeOutputs(writer, args.out, header, args.stl, args.width, dragon.width * args.scale, args.check, args.scale)