320 lines
17 KiB
Python
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)
|