forked from 1b-agent/fieldcraft-upstream
demo video: caption each stage with the MCP tool it calls
A band under the window names the tool behind each stage (open, mesh, batch, check_model, solve, show_results). caption.py draws the bars in the standard library from the interface's Inter SemiBold (OFL): a minimal TrueType glyf reader, a non-zero scanline rasteriser and a zlib PNG writer, unit-tested with python3 -m unittest. ffmpeg pads each picture and composites the bar with its overlay filter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
This commit is contained in:
1 parent
5665cc3d8c
commit
30812250d3
5 files changed
+544
-18
No files matched your search
@@ -6,3 +6,4 @@
|
|||||||
*~
|
*~
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.swp
|
*.swp
|
||||||
|
__pycache__/
|
||||||
@@ -8,6 +8,7 @@ desktop app shows each step.
|
|||||||
python3 tools/demo-video/make.py # build, run, encode into docs/media
|
python3 tools/demo-video/make.py # build, run, encode into docs/media
|
||||||
python3 tools/demo-video/make.py --no-build # with the release binaries already built
|
python3 tools/demo-video/make.py --no-build # with the release binaries already built
|
||||||
python3 tools/demo-video/make.py --frames /tmp/f # also keep the captured pictures
|
python3 tools/demo-video/make.py --frames /tmp/f # also keep the captured pictures
|
||||||
|
cd tools/demo-video && python3 -m unittest # test the caption drawing
|
||||||
```
|
```
|
||||||
|
|
||||||
Needs Python 3 (standard library only), a display for the app window (the pictures are drawn
|
Needs Python 3 (standard library only), a display for the app window (the pictures are drawn
|
||||||
@@ -28,8 +29,15 @@ What it does:
|
|||||||
stepped from 1 to 150 and back. Every picture is the MCP `screenshot` tool with
|
stepped from 1 to 150 and back. Every picture is the MCP `screenshot` tool with
|
||||||
`source: window` (the whole window drawn offscreen, `ui.capture`), turned to the `iso` or
|
`source: window` (the whole window drawn offscreen, `ui.capture`), turned to the `iso` or
|
||||||
`front` view by its `view` argument.
|
`front` view by its `view` argument.
|
||||||
4. Encodes the pictures at 10 frames per second, each held for its stage, into the MP4 (H.264,
|
4. Captions each stage with the MCP tool it calls and what it does (`open · tests/corpus/…`,
|
||||||
1280 px) and the GIF (960 px, its own palette), and prints the tip deflection and the peak
|
`mesh · hex sweep 4 mm`, `solve · static`, …) in a band under the window. `caption.py`
|
||||||
|
draws the bars in the standard library: a minimal TrueType reader (`cmap` format 4, `loca`,
|
||||||
|
simple and composite `glyf` outlines, `hmtx` advances), a scanline rasteriser (non-zero
|
||||||
|
winding, anti-aliased) and a PNG writer, with the interface's own font
|
||||||
|
(`crates/ui-egui/fonts/Inter-SemiBold.ttf`, SIL OFL 1.1). ffmpeg pads each window picture
|
||||||
|
with the band and lays the bar over it with its `overlay` filter.
|
||||||
|
5. Encodes the pictures at 10 frames per second, each held for its stage, into the MP4 (H.264,
|
||||||
|
1280 x 848) and the GIF (960 px, its own palette), and prints the tip deflection and the peak
|
||||||
von Mises stress.
|
von Mises stress.
|
||||||
|
|
||||||
The theme is the app's default (dark).
|
The theme is the app's default (dark).
|
||||||
|
|||||||
@@ -0,0 +1,352 @@
|
|||||||
|
"""Caption pictures for the demo video, drawn in the Python standard library.
|
||||||
|
|
||||||
|
A minimal TrueType reader (the `cmap` format 4, `loca`, `glyf` simple and composite outlines and
|
||||||
|
`hmtx` advances of the TrueType specification), a scanline rasteriser (non-zero winding,
|
||||||
|
sub-scanlines with exact horizontal coverage) and a PNG writer (`zlib`, `struct`). It draws the
|
||||||
|
caption bar under the app window with the interface's own font, `crates/ui-egui/fonts/Inter-SemiBold.ttf` (SIL OFL
|
||||||
|
1.1, ATTRIBUTION.md), and ffmpeg composites it with its `overlay` filter.
|
||||||
|
|
||||||
|
No hinting, kerning or shaping: captions are short ASCII-and-punctuation labels.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import struct
|
||||||
|
import zlib
|
||||||
|
|
||||||
|
# Points per quadratic segment when flattening an outline.
|
||||||
|
CURVE_STEPS = 8
|
||||||
|
# Sub-scanlines per pixel row (vertical anti-aliasing; horizontal coverage is exact).
|
||||||
|
SUBSAMPLES = 5
|
||||||
|
# Nesting of composite glyphs a font may use before we refuse it.
|
||||||
|
MAX_COMPOSITE_DEPTH = 8
|
||||||
|
|
||||||
|
|
||||||
|
class FontError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Font:
|
||||||
|
"""The outlines and advances of a TrueType (`glyf`) font."""
|
||||||
|
|
||||||
|
def __init__(self, data):
|
||||||
|
self.data = data
|
||||||
|
if len(data) < 12:
|
||||||
|
raise FontError("not a font file")
|
||||||
|
(num_tables,) = struct.unpack_from(">H", data, 4)
|
||||||
|
self.tables = {}
|
||||||
|
for i in range(num_tables):
|
||||||
|
tag, _checksum, offset, length = struct.unpack_from(">4sIII", data, 12 + 16 * i)
|
||||||
|
if offset + length > len(data):
|
||||||
|
raise FontError(f"table {tag!r} runs past the end of the file")
|
||||||
|
self.tables[tag.decode("latin-1")] = (offset, length)
|
||||||
|
for tag in ("head", "hhea", "hmtx", "maxp", "cmap", "loca", "glyf"):
|
||||||
|
if tag not in self.tables:
|
||||||
|
raise FontError(f"no {tag} table (only TrueType outlines are supported)")
|
||||||
|
head = self.tables["head"][0]
|
||||||
|
(self.units_per_em,) = struct.unpack_from(">H", data, head + 18)
|
||||||
|
(loc_format,) = struct.unpack_from(">h", data, head + 50)
|
||||||
|
(self.num_glyphs,) = struct.unpack_from(">H", data, self.tables["maxp"][0] + 4)
|
||||||
|
hhea = self.tables["hhea"][0]
|
||||||
|
(self.num_h_metrics,) = struct.unpack_from(">H", data, hhea + 34)
|
||||||
|
loca = self.tables["loca"][0]
|
||||||
|
if loc_format == 0:
|
||||||
|
self.loca = [2 * v for v in struct.unpack_from(f">{self.num_glyphs + 1}H", data, loca)]
|
||||||
|
else:
|
||||||
|
self.loca = list(struct.unpack_from(f">{self.num_glyphs + 1}I", data, loca))
|
||||||
|
self.cmap = self._read_cmap()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load(cls, path):
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
return cls(f.read())
|
||||||
|
|
||||||
|
def _read_cmap(self):
|
||||||
|
"""The Unicode BMP subtable (format 4) as a code point -> glyph map."""
|
||||||
|
data = self.data
|
||||||
|
base = self.tables["cmap"][0]
|
||||||
|
(count,) = struct.unpack_from(">H", data, base + 2)
|
||||||
|
for i in range(count):
|
||||||
|
platform, encoding, offset = struct.unpack_from(">HHI", data, base + 4 + 8 * i)
|
||||||
|
sub = base + offset
|
||||||
|
if (platform, encoding) in ((3, 1), (0, 3)) and struct.unpack_from(">H", data, sub)[0] == 4:
|
||||||
|
return self._read_cmap4(sub)
|
||||||
|
raise FontError("no Unicode BMP (format 4) cmap subtable")
|
||||||
|
|
||||||
|
def _read_cmap4(self, sub):
|
||||||
|
data = self.data
|
||||||
|
(seg_x2,) = struct.unpack_from(">H", data, sub + 6)
|
||||||
|
segs = seg_x2 // 2
|
||||||
|
ends = struct.unpack_from(f">{segs}H", data, sub + 14)
|
||||||
|
starts = struct.unpack_from(f">{segs}H", data, sub + 16 + seg_x2)
|
||||||
|
deltas = struct.unpack_from(f">{segs}h", data, sub + 16 + 2 * seg_x2)
|
||||||
|
range_at = sub + 16 + 3 * seg_x2
|
||||||
|
ranges = struct.unpack_from(f">{segs}H", data, range_at)
|
||||||
|
table = {}
|
||||||
|
for i in range(segs):
|
||||||
|
for code in range(starts[i], ends[i] + 1):
|
||||||
|
if code == 0xFFFF:
|
||||||
|
continue
|
||||||
|
if ranges[i] == 0:
|
||||||
|
glyph = (code + deltas[i]) & 0xFFFF
|
||||||
|
else:
|
||||||
|
at = range_at + 2 * i + ranges[i] + 2 * (code - starts[i])
|
||||||
|
(glyph,) = struct.unpack_from(">H", data, at)
|
||||||
|
if glyph:
|
||||||
|
glyph = (glyph + deltas[i]) & 0xFFFF
|
||||||
|
if glyph:
|
||||||
|
table[code] = glyph
|
||||||
|
return table
|
||||||
|
|
||||||
|
def glyph(self, char):
|
||||||
|
"""The glyph index of a character (0, the missing glyph, when the font has none)."""
|
||||||
|
return self.cmap.get(ord(char), 0)
|
||||||
|
|
||||||
|
def advance(self, glyph):
|
||||||
|
hmtx = self.tables["hmtx"][0]
|
||||||
|
index = min(glyph, self.num_h_metrics - 1)
|
||||||
|
return struct.unpack_from(">H", self.data, hmtx + 4 * index)[0]
|
||||||
|
|
||||||
|
def contours(self, glyph, depth=0):
|
||||||
|
"""The glyph's outline as closed polylines in font units (y up)."""
|
||||||
|
if depth > MAX_COMPOSITE_DEPTH:
|
||||||
|
raise FontError("composite glyphs nested too deeply")
|
||||||
|
if not 0 <= glyph < self.num_glyphs:
|
||||||
|
raise FontError(f"glyph {glyph} out of range")
|
||||||
|
start, end = self.loca[glyph], self.loca[glyph + 1]
|
||||||
|
if start == end:
|
||||||
|
return [] # no outline (a space)
|
||||||
|
at = self.tables["glyf"][0] + start
|
||||||
|
(n,) = struct.unpack_from(">h", self.data, at)
|
||||||
|
if n >= 0:
|
||||||
|
return self._simple(at, n)
|
||||||
|
return self._composite(at + 10, depth)
|
||||||
|
|
||||||
|
def _simple(self, at, n):
|
||||||
|
data = self.data
|
||||||
|
ends = struct.unpack_from(f">{n}H", data, at + 10)
|
||||||
|
points = ends[-1] + 1 if n else 0
|
||||||
|
at += 10 + 2 * n
|
||||||
|
(instructions,) = struct.unpack_from(">H", data, at)
|
||||||
|
at += 2 + instructions
|
||||||
|
flags = []
|
||||||
|
while len(flags) < points:
|
||||||
|
flag = data[at]
|
||||||
|
at += 1
|
||||||
|
repeat = 1
|
||||||
|
if flag & 8:
|
||||||
|
repeat += data[at]
|
||||||
|
at += 1
|
||||||
|
flags.extend([flag] * repeat)
|
||||||
|
flags = flags[:points]
|
||||||
|
|
||||||
|
def coords(short_bit, same_bit):
|
||||||
|
nonlocal at
|
||||||
|
values, value = [], 0
|
||||||
|
for flag in flags:
|
||||||
|
if flag & short_bit:
|
||||||
|
delta = data[at]
|
||||||
|
at += 1
|
||||||
|
value += delta if flag & same_bit else -delta
|
||||||
|
elif not flag & same_bit:
|
||||||
|
value += struct.unpack_from(">h", data, at)[0]
|
||||||
|
at += 2
|
||||||
|
values.append(value)
|
||||||
|
return values
|
||||||
|
|
||||||
|
xs = coords(2, 16)
|
||||||
|
ys = coords(4, 32)
|
||||||
|
contours, first = [], 0
|
||||||
|
for last in ends:
|
||||||
|
pts = [(xs[i], ys[i], bool(flags[i] & 1)) for i in range(first, last + 1)]
|
||||||
|
first = last + 1
|
||||||
|
if pts:
|
||||||
|
contours.append(_flatten(pts))
|
||||||
|
return contours
|
||||||
|
|
||||||
|
def _composite(self, at, depth):
|
||||||
|
data = self.data
|
||||||
|
contours = []
|
||||||
|
while True:
|
||||||
|
flags, glyph = struct.unpack_from(">HH", data, at)
|
||||||
|
at += 4
|
||||||
|
if flags & 1:
|
||||||
|
arg1, arg2 = struct.unpack_from(">hh", data, at)
|
||||||
|
at += 4
|
||||||
|
else:
|
||||||
|
arg1, arg2 = struct.unpack_from(">bb", data, at)
|
||||||
|
at += 2
|
||||||
|
if not flags & 2:
|
||||||
|
raise FontError("composite glyphs placed by point matching are not supported")
|
||||||
|
a, b, c, d = 1.0, 0.0, 0.0, 1.0
|
||||||
|
if flags & 8:
|
||||||
|
a = d = _f2dot14(data, at)
|
||||||
|
at += 2
|
||||||
|
elif flags & 0x40:
|
||||||
|
a, d = _f2dot14(data, at), _f2dot14(data, at + 2)
|
||||||
|
at += 4
|
||||||
|
elif flags & 0x80:
|
||||||
|
a, b, c, d = (_f2dot14(data, at + 2 * k) for k in range(4))
|
||||||
|
at += 8
|
||||||
|
for contour in self.contours(glyph, depth + 1):
|
||||||
|
contours.append([(a * x + c * y + arg1, b * x + d * y + arg2) for x, y in contour])
|
||||||
|
if not flags & 0x20:
|
||||||
|
return contours
|
||||||
|
|
||||||
|
|
||||||
|
def _f2dot14(data, at):
|
||||||
|
return struct.unpack_from(">h", data, at)[0] / 16384.0
|
||||||
|
|
||||||
|
|
||||||
|
def _flatten(pts):
|
||||||
|
"""A closed quadratic B-spline contour (on- and off-curve points) as a polyline."""
|
||||||
|
# Start on an on-curve point; with none, at the midpoint of the first two off-curve ones.
|
||||||
|
start = next((i for i, p in enumerate(pts) if p[2]), None)
|
||||||
|
if start is None:
|
||||||
|
(x0, y0, _), (x1, y1, _) = pts[0], pts[1 % len(pts)]
|
||||||
|
pts = [((x0 + x1) / 2, (y0 + y1) / 2, True)] + pts
|
||||||
|
start = 0
|
||||||
|
pts = pts[start:] + pts[:start]
|
||||||
|
out = [(pts[0][0], pts[0][1])]
|
||||||
|
control = None
|
||||||
|
for x, y, on in pts[1:] + pts[:1]:
|
||||||
|
if on:
|
||||||
|
if control is None:
|
||||||
|
out.append((x, y))
|
||||||
|
else:
|
||||||
|
out.extend(_quad(out[-1], control, (x, y)))
|
||||||
|
control = None
|
||||||
|
else:
|
||||||
|
if control is not None:
|
||||||
|
mid = ((control[0] + x) / 2, (control[1] + y) / 2)
|
||||||
|
out.extend(_quad(out[-1], control, mid))
|
||||||
|
control = (x, y)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _quad(p0, p1, p2):
|
||||||
|
pts = []
|
||||||
|
for k in range(1, CURVE_STEPS + 1):
|
||||||
|
t = k / CURVE_STEPS
|
||||||
|
u = 1 - t
|
||||||
|
pts.append((u * u * p0[0] + 2 * u * t * p1[0] + t * t * p2[0], u * u * p0[1] + 2 * u * t * p1[1] + t * t * p2[1]))
|
||||||
|
return pts
|
||||||
|
|
||||||
|
|
||||||
|
def fill(polygons, width, height):
|
||||||
|
"""Coverage (0..1, row-major) of closed polygons in pixel coordinates (y down), non-zero rule."""
|
||||||
|
cover = [0.0] * (width * height)
|
||||||
|
edges = []
|
||||||
|
for poly in polygons:
|
||||||
|
for (x0, y0), (x1, y1) in zip(poly, poly[1:] + poly[:1]):
|
||||||
|
if y0 != y1:
|
||||||
|
edges.append((x0, y0, x1, y1, 1 if y1 > y0 else -1))
|
||||||
|
if not edges:
|
||||||
|
return cover
|
||||||
|
top = max(0, int(math.floor(min(min(e[1], e[3]) for e in edges))))
|
||||||
|
bottom = min(height, int(math.ceil(max(max(e[1], e[3]) for e in edges))))
|
||||||
|
weight = 1.0 / SUBSAMPLES
|
||||||
|
for row in range(top, bottom):
|
||||||
|
base = row * width
|
||||||
|
for s in range(SUBSAMPLES):
|
||||||
|
y = row + (s + 0.5) / SUBSAMPLES
|
||||||
|
crossings = []
|
||||||
|
for x0, y0, x1, y1, wind in edges:
|
||||||
|
if (y0 <= y < y1) or (y1 <= y < y0):
|
||||||
|
crossings.append((x0 + (y - y0) * (x1 - x0) / (y1 - y0), wind))
|
||||||
|
crossings.sort()
|
||||||
|
winding = 0
|
||||||
|
for (xa, wind), (xb, _) in zip(crossings, crossings[1:]):
|
||||||
|
winding += wind
|
||||||
|
if winding:
|
||||||
|
_span(cover, base, width, xa, xb, weight)
|
||||||
|
return cover
|
||||||
|
|
||||||
|
|
||||||
|
def _span(cover, base, width, xa, xb, weight):
|
||||||
|
"""Add `weight` times the covered fraction of each pixel in [xa, xb) of one row."""
|
||||||
|
xa, xb = max(xa, 0.0), min(xb, float(width))
|
||||||
|
if xb <= xa:
|
||||||
|
return
|
||||||
|
first, last = int(xa), min(int(math.ceil(xb)) - 1, width - 1)
|
||||||
|
if first == last:
|
||||||
|
cover[base + first] += weight * (xb - xa)
|
||||||
|
return
|
||||||
|
cover[base + first] += weight * (first + 1 - xa)
|
||||||
|
for px in range(first + 1, last):
|
||||||
|
cover[base + px] += weight
|
||||||
|
cover[base + last] += weight * (xb - last)
|
||||||
|
|
||||||
|
|
||||||
|
def text_width(font, text, size):
|
||||||
|
return sum(font.advance(font.glyph(ch)) for ch in text) * size / font.units_per_em
|
||||||
|
|
||||||
|
|
||||||
|
def text_polygons(font, text, size, x, baseline):
|
||||||
|
"""The outlines of a line of text at `size` pixels per em, its baseline at `baseline`."""
|
||||||
|
scale = size / font.units_per_em
|
||||||
|
polygons = []
|
||||||
|
for ch in text:
|
||||||
|
glyph = font.glyph(ch)
|
||||||
|
for contour in font.contours(glyph):
|
||||||
|
polygons.append([(x + px * scale, baseline - py * scale) for px, py in contour])
|
||||||
|
x += font.advance(glyph) * scale
|
||||||
|
return polygons, x
|
||||||
|
|
||||||
|
|
||||||
|
class Canvas:
|
||||||
|
"""An opaque RGB picture that shapes are painted over, anti-aliased by their coverage."""
|
||||||
|
|
||||||
|
def __init__(self, width, height, background):
|
||||||
|
self.width, self.height = width, height
|
||||||
|
self.rgb = [list(background) for _ in range(width * height)]
|
||||||
|
|
||||||
|
def paint(self, polygons, color):
|
||||||
|
"""Paint polygons in an sRGB colour (r, g, b bytes)."""
|
||||||
|
cover = fill(polygons, self.width, self.height)
|
||||||
|
for i, c in enumerate(cover):
|
||||||
|
if c > 0.0:
|
||||||
|
c = min(c, 1.0)
|
||||||
|
self.rgb[i] = [d + (s - d) * c for d, s in zip(self.rgb[i], color)]
|
||||||
|
|
||||||
|
def png(self):
|
||||||
|
rows = []
|
||||||
|
for y in range(self.height):
|
||||||
|
row = bytearray(b"\0") # filter: none
|
||||||
|
for px in self.rgb[y * self.width:(y + 1) * self.width]:
|
||||||
|
row.extend(min(255, int(round(v))) for v in px)
|
||||||
|
rows.append(bytes(row))
|
||||||
|
return png(self.width, self.height, b"".join(rows))
|
||||||
|
|
||||||
|
|
||||||
|
def png(width, height, scanlines):
|
||||||
|
"""An 8-bit RGB PNG from filtered scanlines (each row prefixed by its filter byte)."""
|
||||||
|
|
||||||
|
def chunk(tag, body):
|
||||||
|
return struct.pack(">I", len(body)) + tag + body + struct.pack(">I", zlib.crc32(tag + body) & 0xFFFFFFFF)
|
||||||
|
|
||||||
|
header = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
|
||||||
|
return b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) + chunk(b"IDAT", zlib.compress(scanlines, 9)) + chunk(b"IEND", b"")
|
||||||
|
|
||||||
|
|
||||||
|
# The caption bar's look: the app's dark panel colour, the tool name in its accent blue.
|
||||||
|
BAR = (22, 26, 34)
|
||||||
|
TOOL = (110, 168, 255)
|
||||||
|
TEXT = (236, 239, 244)
|
||||||
|
SEPARATOR = " \u00b7 "
|
||||||
|
|
||||||
|
|
||||||
|
def caption_bar(font, size, tool, detail, font_size=22):
|
||||||
|
"""A caption bar of `size` (width, height) pixels: the MCP tool's name, then what it does,
|
||||||
|
centred."""
|
||||||
|
width, height = size
|
||||||
|
rest = SEPARATOR + detail if detail else ""
|
||||||
|
text_w = text_width(font, tool + rest, font_size)
|
||||||
|
canvas = Canvas(width, height, BAR)
|
||||||
|
# The baseline centres the font's cap height (about 0.73 em for Inter) in the bar.
|
||||||
|
baseline = height / 2 + 0.73 * font_size / 2
|
||||||
|
polygons, x = text_polygons(font, tool, font_size, (width - text_w) / 2, baseline)
|
||||||
|
canvas.paint(polygons, TOOL)
|
||||||
|
if rest:
|
||||||
|
polygons, _ = text_polygons(font, rest, font_size, x, baseline)
|
||||||
|
canvas.paint(polygons, TEXT)
|
||||||
|
return canvas.png()
|
||||||
+62
-16
@@ -3,7 +3,8 @@
|
|||||||
|
|
||||||
Every analysis step goes through the MCP server (`fieldcraft-cli mcp --connect`), bridged to
|
Every analysis step goes through the MCP server (`fieldcraft-cli mcp --connect`), bridged to
|
||||||
the desktop app, and every frame is the app window captured offscreen through the MCP
|
the desktop app, and every frame is the app window captured offscreen through the MCP
|
||||||
`screenshot` tool (`source: window`). See README.md beside this file.
|
`screenshot` tool (`source: window`). A caption bar names the tool behind each stage
|
||||||
|
(caption.py draws it; ffmpeg's `overlay` filter composites it). See README.md beside this file.
|
||||||
|
|
||||||
Python standard library only; ffmpeg encodes the frames.
|
Python standard library only; ffmpeg encodes the frames.
|
||||||
"""
|
"""
|
||||||
@@ -20,6 +21,8 @@ import tempfile
|
|||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import caption
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
# The analysis: a cantilever I-beam lying along x (tests/corpus/cantilever-i-beam: 40 x 40 mm,
|
# The analysis: a cantilever I-beam lying along x (tests/corpus/cantilever-i-beam: 40 x 40 mm,
|
||||||
@@ -47,9 +50,13 @@ HOLD_LONG = 20 # the stress result
|
|||||||
SWING = 12 # pictures from undeformed to the largest exaggeration
|
SWING = 12 # pictures from undeformed to the largest exaggeration
|
||||||
MAX_SCALE = 150.0 # the largest displacement (0.18 mm) drawn about 26 mm on the 200 mm beam
|
MAX_SCALE = 150.0 # the largest displacement (0.18 mm) drawn about 26 mm on the 200 mm beam
|
||||||
|
|
||||||
# The captured window, in points (the app's own pixels-per-point scale on top).
|
# The captured window, in points (the app's own pixels-per-point scale on top); the video's size.
|
||||||
WINDOW = (1280, 800)
|
WINDOW = (1280, 800)
|
||||||
|
|
||||||
|
# The caption bar, a band under the window (so it never hides the model, the legend or a panel).
|
||||||
|
CAPTION_FONT = ROOT / "crates" / "ui-egui" / "fonts" / "Inter-SemiBold.ttf"
|
||||||
|
CAPTION_HEIGHT = 48
|
||||||
|
|
||||||
|
|
||||||
def log(msg):
|
def log(msg):
|
||||||
print(f"demo-video: {msg}", file=sys.stderr, flush=True)
|
print(f"demo-video: {msg}", file=sys.stderr, flush=True)
|
||||||
@@ -140,30 +147,54 @@ class Film:
|
|||||||
def __init__(self, mcp, frames_dir):
|
def __init__(self, mcp, frames_dir):
|
||||||
self.mcp = mcp
|
self.mcp = mcp
|
||||||
self.dir = frames_dir
|
self.dir = frames_dir
|
||||||
self.shots = [] # (file, frames)
|
self.font = caption.Font.load(CAPTION_FONT)
|
||||||
|
self.captions = {} # (tool, detail) -> its picture
|
||||||
|
self.caption = None
|
||||||
|
self.shots = [] # (file, frames, caption picture)
|
||||||
|
|
||||||
def shoot(self, frames, view=None):
|
def say(self, tool, detail):
|
||||||
|
"""Caption the next pictures with the MCP tool being called and what it does."""
|
||||||
|
key = (tool, detail)
|
||||||
|
if key not in self.captions:
|
||||||
|
path = self.dir / f"caption{len(self.captions):03d}.png"
|
||||||
|
path.write_bytes(caption.caption_bar(self.font, (WINDOW[0], CAPTION_HEIGHT), tool, detail))
|
||||||
|
self.captions[key] = path
|
||||||
|
self.caption = self.captions[key]
|
||||||
|
|
||||||
|
def shoot(self, frames, view=None, camera=None):
|
||||||
|
if self.caption is None:
|
||||||
|
raise SystemExit("film: a picture without a caption")
|
||||||
args = {"source": "window", "width": WINDOW[0], "height": WINDOW[1]}
|
args = {"source": "window", "width": WINDOW[0], "height": WINDOW[1]}
|
||||||
if view:
|
if view:
|
||||||
args["view"] = view
|
args["view"] = view
|
||||||
|
if camera:
|
||||||
|
args["camera"] = camera
|
||||||
content = self.mcp.tool("screenshot", args)
|
content = self.mcp.tool("screenshot", args)
|
||||||
png = next((c["data"] for c in content if c.get("type") == "image"), None)
|
png = next((c["data"] for c in content if c.get("type") == "image"), None)
|
||||||
if png is None:
|
if png is None:
|
||||||
raise SystemExit("screenshot: no image in the reply")
|
raise SystemExit("screenshot: no image in the reply")
|
||||||
path = self.dir / f"shot{len(self.shots):04d}.png"
|
path = self.dir / f"shot{len(self.shots):04d}.png"
|
||||||
path.write_bytes(base64.b64decode(png))
|
path.write_bytes(base64.b64decode(png))
|
||||||
self.shots.append((path, frames))
|
self.shots.append((path, frames, self.caption))
|
||||||
|
|
||||||
|
def hold(self, frames):
|
||||||
|
"""Hold the last picture, under the current caption, when the window has not changed."""
|
||||||
|
if not self.shots:
|
||||||
|
raise SystemExit("film: nothing to hold")
|
||||||
|
self.shots.append((self.shots[-1][0], frames, self.caption))
|
||||||
|
|
||||||
def sequence(self, folder):
|
def sequence(self, folder):
|
||||||
"""Lay the video out as numbered frames (links to the shots); return their pattern and count."""
|
"""Lay the video out as numbered frames and their captions (links to the pictures);
|
||||||
|
return the two patterns and the frame count."""
|
||||||
shutil.rmtree(folder, ignore_errors=True)
|
shutil.rmtree(folder, ignore_errors=True)
|
||||||
folder.mkdir(parents=True)
|
(folder / "captions").mkdir(parents=True)
|
||||||
n = 0
|
n = 0
|
||||||
for shot, frames in self.shots:
|
for shot, frames, bar in self.shots:
|
||||||
for _ in range(frames):
|
for _ in range(frames):
|
||||||
os.link(shot, folder / f"{n:05d}.png")
|
os.link(shot, folder / f"{n:05d}.png")
|
||||||
|
os.link(bar, folder / "captions" / f"{n:05d}.png")
|
||||||
n += 1
|
n += 1
|
||||||
return str(folder / "%05d.png"), n
|
return str(folder / "%05d.png"), str(folder / "captions" / "%05d.png"), n
|
||||||
|
|
||||||
|
|
||||||
def hand_calculation():
|
def hand_calculation():
|
||||||
@@ -231,16 +262,19 @@ def batch(mcp, commands):
|
|||||||
def analyse(mcp, film, design, work):
|
def analyse(mcp, film, design, work):
|
||||||
"""The analysis, every step an MCP tool call, with the pictures a viewer follows."""
|
"""The analysis, every step an MCP tool call, with the pictures a viewer follows."""
|
||||||
# 1. Open the part.
|
# 1. Open the part.
|
||||||
|
film.say("open", f"tests/corpus/{PART}")
|
||||||
mcp.call("open", {"path": str(design)})
|
mcp.call("open", {"path": str(design)})
|
||||||
film.shoot(HOLD, view="iso")
|
film.shoot(HOLD, view="iso")
|
||||||
|
|
||||||
# 2. Hex mesh: the extruded section sweeps into structured hexahedra.
|
# 2. Hex mesh: the extruded section sweeps into structured hexahedra.
|
||||||
|
film.say("mesh", f"hex sweep {SIZE_MM:g} mm")
|
||||||
mesh = mcp.call("mesh", {"method": "hex_sweep", "params": {"body": BODY, "size": SIZE_MM}})
|
mesh = mcp.call("mesh", {"method": "hex_sweep", "params": {"body": BODY, "size": SIZE_MM}})
|
||||||
log(f"mesh: {mesh['mesh']['elements']} elements, {mesh['mesh']['nodes']} nodes")
|
log(f"mesh: {mesh['mesh']['elements']} elements, {mesh['mesh']['nodes']} nodes")
|
||||||
film.shoot(HOLD)
|
film.shoot(HOLD)
|
||||||
|
|
||||||
# 3. Material and section, then supports, then the load and the step: one picture each, so
|
# 3. Material and section, then supports, then the load and the step: one picture each, so
|
||||||
# the browser's counts grow as the model is defined.
|
# the browser's counts grow as the model is defined.
|
||||||
|
film.say("batch", "steel, solid section")
|
||||||
batch(mcp, [
|
batch(mcp, [
|
||||||
{"command": "model.units", "params": {"system": "mm-t-s-N-MPa"}},
|
{"command": "model.units", "params": {"system": "mm-t-s-N-MPa"}},
|
||||||
{"command": "model.set.create", "params": {"name": "beam", "geometry": [{"body": BODY}]}},
|
{"command": "model.set.create", "params": {"name": "beam", "geometry": [{"body": BODY}]}},
|
||||||
@@ -248,11 +282,13 @@ def analyse(mcp, film, design, work):
|
|||||||
{"command": "model.section.create", "params": {"name": "solid", "set": "beam", "material": "Steel", "kind": {"type": "solid"}, "formulation": {"incompatible_modes": True}}},
|
{"command": "model.section.create", "params": {"name": "solid", "set": "beam", "material": "Steel", "kind": {"type": "solid"}, "formulation": {"incompatible_modes": True}}},
|
||||||
])
|
])
|
||||||
film.shoot(HOLD_SHORT)
|
film.shoot(HOLD_SHORT)
|
||||||
|
film.say("batch", "clamp at x = 0")
|
||||||
batch(mcp, [
|
batch(mcp, [
|
||||||
{"command": "model.set.create", "params": {"name": "root", "geometry": [{"body": BODY, "face": ROOT_FACE}]}},
|
{"command": "model.set.create", "params": {"name": "root", "geometry": [{"body": BODY, "face": ROOT_FACE}]}},
|
||||||
{"command": "model.boundary_condition.create", "params": {"name": "clamp", "type": "fixed", "target": {"set": "root"}}},
|
{"command": "model.boundary_condition.create", "params": {"name": "clamp", "type": "fixed", "target": {"set": "root"}}},
|
||||||
])
|
])
|
||||||
film.shoot(HOLD_SHORT)
|
film.shoot(HOLD_SHORT)
|
||||||
|
film.say("batch", f"{LOAD_N:g} N tip load, static step")
|
||||||
batch(mcp, [
|
batch(mcp, [
|
||||||
# The end section stays plane: a kinematic coupling to a point on the beam's axis carries
|
# The end section stays plane: a kinematic coupling to a point on the beam's axis carries
|
||||||
# the load, so no load-introduction peak hides the root stress.
|
# the load, so no load-introduction peak hides the root stress.
|
||||||
@@ -266,15 +302,20 @@ def analyse(mcp, film, design, work):
|
|||||||
if not check.get("ready"):
|
if not check.get("ready"):
|
||||||
raise SystemExit(f"the model is not ready: {check}")
|
raise SystemExit(f"the model is not ready: {check}")
|
||||||
film.shoot(HOLD_SHORT)
|
film.shoot(HOLD_SHORT)
|
||||||
|
film.say("check_model", "ready")
|
||||||
|
film.hold(HOLD_SHORT)
|
||||||
|
|
||||||
# 4. Solve.
|
# 4. Solve.
|
||||||
|
film.say("solve", "static")
|
||||||
solve = mcp.call("solve", {"analysis": "static", "step": STEP})
|
solve = mcp.call("solve", {"analysis": "static", "step": STEP})
|
||||||
log(f"solve: {solve.get('equations')} equations")
|
log(f"solve: {solve.get('equations')} equations")
|
||||||
|
film.shoot(HOLD_SHORT)
|
||||||
|
|
||||||
# 5. Results: the tip deflection, then von Mises contours on the true (scale 1) shape.
|
# 5. Results: the tip deflection, then von Mises contours on the true (scale 1) shape.
|
||||||
disp = mcp.call("show_results", {"step": STEP, "field": "displacement"})
|
disp = mcp.call("show_results", {"step": STEP, "field": "displacement"})
|
||||||
tip = disp["contour"]["range"]["max"]["value"]
|
tip = disp["contour"]["range"]["max"]["value"]
|
||||||
root_stress = root_bending_stress(mcp, work)
|
root_stress = root_bending_stress(mcp, work)
|
||||||
|
film.say("show_results", "von Mises")
|
||||||
vm = mcp.call("show_results", {"step": STEP, "field": "von_mises", "bands": 12, "deformed": True, "scale": 1})
|
vm = mcp.call("show_results", {"step": STEP, "field": "von_mises", "bands": 12, "deformed": True, "scale": 1})
|
||||||
peak = vm["contour"]["range"]["max"]["value"]
|
peak = vm["contour"]["range"]["max"]["value"]
|
||||||
log(f"tip deflection {tip:.4f} mm, peak von Mises {peak:.1f} MPa")
|
log(f"tip deflection {tip:.4f} mm, peak von Mises {peak:.1f} MPa")
|
||||||
@@ -282,6 +323,7 @@ def analyse(mcp, film, design, work):
|
|||||||
|
|
||||||
# 6. The deformed shape, swung from the true shape to an exaggeration and back, in the iso
|
# 6. The deformed shape, swung from the true shape to an exaggeration and back, in the iso
|
||||||
# view and then from the side, ending held at the exaggeration.
|
# view and then from the side, ending held at the exaggeration.
|
||||||
|
film.say("show_results", f"deformed, up to \u00d7{MAX_SCALE:g}")
|
||||||
ramp = [max(1.0, MAX_SCALE * i / SWING) for i in range(SWING + 1)]
|
ramp = [max(1.0, MAX_SCALE * i / SWING) for i in range(SWING + 1)]
|
||||||
for view, scales in (("iso", ramp[1:] + ramp[-2::-1]), ("front", ramp[1:])):
|
for view, scales in (("iso", ramp[1:] + ramp[-2::-1]), ("front", ramp[1:])):
|
||||||
for i, scale in enumerate(scales):
|
for i, scale in enumerate(scales):
|
||||||
@@ -293,21 +335,25 @@ def analyse(mcp, film, design, work):
|
|||||||
|
|
||||||
def encode(film, frames_dir, mp4, gif):
|
def encode(film, frames_dir, mp4, gif):
|
||||||
ffmpeg = os.environ.get("FFMPEG", shutil.which("ffmpeg") or "ffmpeg")
|
ffmpeg = os.environ.get("FFMPEG", shutil.which("ffmpeg") or "ffmpeg")
|
||||||
pattern, count = film.sequence(frames_dir / "sequence")
|
pattern, captions, count = film.sequence(frames_dir / "sequence")
|
||||||
log(f"encoding {len(film.shots)} pictures as {count} frames, {count / FPS:.1f} s")
|
log(f"encoding {len(film.shots)} pictures as {count} frames, {count / FPS:.1f} s")
|
||||||
src = ["-framerate", str(FPS), "-i", pattern]
|
# The window pictures scaled to the window's size in points, with their caption bars laid
|
||||||
|
# over a band added below.
|
||||||
|
src = ["-framerate", str(FPS), "-i", pattern, "-framerate", str(FPS), "-i", captions]
|
||||||
|
width, height = WINDOW
|
||||||
|
captioned = (f"[0:v]scale={width}:{height}:flags=lanczos,pad={width}:{height + CAPTION_HEIGHT}:0:0[w];"
|
||||||
|
f"[w][1:v]overlay=0:{height}")
|
||||||
common = [ffmpeg, "-hide_banner", "-loglevel", "error", "-y"]
|
common = [ffmpeg, "-hide_banner", "-loglevel", "error", "-y"]
|
||||||
mp4.parent.mkdir(parents=True, exist_ok=True)
|
mp4.parent.mkdir(parents=True, exist_ok=True)
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
[*common, *src, "-vf", "scale=1280:-2:flags=lanczos", "-c:v", "libx264", "-preset", "veryslow", "-crf", "26",
|
[*common, *src, "-filter_complex", captioned, "-c:v", "libx264", "-preset", "veryslow", "-crf", "26",
|
||||||
"-pix_fmt", "yuv420p", "-movflags", "+faststart", str(mp4)],
|
"-pix_fmt", "yuv420p", "-movflags", "+faststart", str(mp4)],
|
||||||
check=True,
|
check=True,
|
||||||
)
|
)
|
||||||
palette = frames_dir / "palette.png"
|
|
||||||
subprocess.run([*common, *src, "-vf", "scale=960:-1:flags=lanczos,palettegen=stats_mode=diff", str(palette)], check=True)
|
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
[*common, *src, "-i", str(palette), "-lavfi",
|
[*common, *src, "-filter_complex",
|
||||||
"scale=960:-1:flags=lanczos[v];[v][1:v]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle", str(gif)],
|
f"{captioned},scale=960:-1:flags=lanczos,split[a][b];[a]palettegen=stats_mode=diff[p];"
|
||||||
|
"[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle", str(gif)],
|
||||||
check=True,
|
check=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""Tests of the caption pictures: `python3 -m unittest` in tools/demo-video."""
|
||||||
|
|
||||||
|
import struct
|
||||||
|
import unittest
|
||||||
|
import zlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import caption
|
||||||
|
|
||||||
|
FONT = Path(__file__).resolve().parents[2] / "crates" / "ui-egui" / "fonts" / "Inter-SemiBold.ttf"
|
||||||
|
|
||||||
|
|
||||||
|
def decode(data):
|
||||||
|
"""Width, height and RGB rows of a PNG written by `caption.png` (filter 0, one IDAT)."""
|
||||||
|
assert data[:8] == b"\x89PNG\r\n\x1a\n"
|
||||||
|
at, chunks = 8, {}
|
||||||
|
while at < len(data):
|
||||||
|
(length,) = struct.unpack_from(">I", data, at)
|
||||||
|
tag, body = data[at + 4:at + 8], data[at + 8:at + 8 + length]
|
||||||
|
(crc,) = struct.unpack_from(">I", data, at + 8 + length)
|
||||||
|
assert crc == zlib.crc32(tag + body) & 0xFFFFFFFF, tag
|
||||||
|
chunks[tag] = body
|
||||||
|
at += 12 + length
|
||||||
|
width, height, depth, kind = struct.unpack_from(">IIBB", chunks[b"IHDR"])
|
||||||
|
assert (depth, kind) == (8, 2)
|
||||||
|
raw = zlib.decompress(chunks[b"IDAT"])
|
||||||
|
stride = 1 + 3 * width
|
||||||
|
rows = [raw[y * stride + 1:(y + 1) * stride] for y in range(height)]
|
||||||
|
assert all(raw[y * stride] == 0 for y in range(height))
|
||||||
|
return width, height, rows
|
||||||
|
|
||||||
|
|
||||||
|
class Fill(unittest.TestCase):
|
||||||
|
def test_square_covers_its_pixels_and_partial_edges(self):
|
||||||
|
cover = caption.fill([[(1, 1), (3.5, 1), (3.5, 3), (1, 3)]], 5, 4)
|
||||||
|
at = lambda x, y: cover[y * 5 + x]
|
||||||
|
self.assertAlmostEqual(at(1, 1), 1.0)
|
||||||
|
self.assertAlmostEqual(at(2, 2), 1.0)
|
||||||
|
self.assertAlmostEqual(at(3, 1), 0.5)
|
||||||
|
self.assertAlmostEqual(at(0, 1), 0.0)
|
||||||
|
self.assertAlmostEqual(at(4, 2), 0.0)
|
||||||
|
self.assertAlmostEqual(at(1, 0), 0.0)
|
||||||
|
self.assertAlmostEqual(sum(cover), 2.5 * 2)
|
||||||
|
|
||||||
|
def test_nonzero_winding_fills_overlaps_once_and_leaves_holes(self):
|
||||||
|
outer = [(0, 0), (6, 0), (6, 6), (0, 6)]
|
||||||
|
hole = [(2, 2), (2, 4), (4, 4), (4, 2)] # opposite direction
|
||||||
|
cover = caption.fill([outer, hole], 6, 6)
|
||||||
|
self.assertAlmostEqual(cover[3 * 6 + 3], 0.0)
|
||||||
|
self.assertAlmostEqual(cover[1 * 6 + 1], 1.0)
|
||||||
|
twice = caption.fill([outer, outer], 6, 6)
|
||||||
|
self.assertAlmostEqual(max(twice), 1.0)
|
||||||
|
|
||||||
|
def test_shapes_outside_the_picture_are_clipped(self):
|
||||||
|
cover = caption.fill([[(-5, -5), (20, -5), (20, 20), (-5, 20)]], 3, 2)
|
||||||
|
self.assertEqual(cover, [1.0] * 6)
|
||||||
|
|
||||||
|
|
||||||
|
class Png(unittest.TestCase):
|
||||||
|
def test_round_trip(self):
|
||||||
|
canvas = caption.Canvas(3, 2, (0, 0, 0))
|
||||||
|
canvas.paint([[(1, 1), (2, 1), (2, 2), (1, 2)]], (255, 0, 0))
|
||||||
|
canvas.paint([[(2, 1), (2.5, 1), (2.5, 2), (2, 2)]], (0, 0, 255)) # half a pixel
|
||||||
|
width, height, rows = decode(canvas.png())
|
||||||
|
self.assertEqual((width, height), (3, 2))
|
||||||
|
self.assertEqual(rows[0], bytes(9))
|
||||||
|
self.assertEqual(rows[1], bytes((0, 0, 0, 255, 0, 0, 0, 0, 128)))
|
||||||
|
|
||||||
|
|
||||||
|
class Font(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.font = caption.Font.load(FONT)
|
||||||
|
|
||||||
|
def test_metrics(self):
|
||||||
|
self.assertGreater(self.font.units_per_em, 0)
|
||||||
|
self.assertGreater(self.font.advance(self.font.glyph("m")), self.font.advance(self.font.glyph("i")))
|
||||||
|
self.assertEqual(self.font.contours(self.font.glyph(" ")), [])
|
||||||
|
|
||||||
|
def test_outlines_have_their_holes(self):
|
||||||
|
size = 100
|
||||||
|
polygons, _ = caption.text_polygons(self.font, "o", size, 0, size)
|
||||||
|
self.assertEqual(len(polygons), 2)
|
||||||
|
cover = caption.fill(polygons, size, size)
|
||||||
|
xs = [p[0] for p in polygons[0]]
|
||||||
|
ys = [p[1] for p in polygons[0]]
|
||||||
|
cx, cy = int((min(xs) + max(xs)) / 2), int((min(ys) + max(ys)) / 2)
|
||||||
|
self.assertAlmostEqual(cover[cy * size + cx], 0.0) # the counter
|
||||||
|
self.assertGreater(cover[cy * size + int(min(xs)) + 3], 0.99) # the stroke
|
||||||
|
|
||||||
|
def test_composite_glyphs(self):
|
||||||
|
# An accented letter is a base glyph plus a mark placed by an offset.
|
||||||
|
e = self.font.contours(self.font.glyph("e"))
|
||||||
|
e_acute = self.font.contours(self.font.glyph("é"))
|
||||||
|
self.assertGreater(len(e_acute), len(e))
|
||||||
|
self.assertGreater(max(y for c in e_acute for _, y in c), max(y for c in e for _, y in c))
|
||||||
|
|
||||||
|
def test_hostile_data_is_refused(self):
|
||||||
|
with self.assertRaises(caption.FontError):
|
||||||
|
caption.Font(b"")
|
||||||
|
with self.assertRaises(caption.FontError):
|
||||||
|
self.font.contours(self.font.num_glyphs)
|
||||||
|
|
||||||
|
|
||||||
|
class CaptionBar(unittest.TestCase):
|
||||||
|
def test_text_is_centred_on_the_bar(self):
|
||||||
|
font = caption.Font.load(FONT)
|
||||||
|
width, height, rows = decode(caption.caption_bar(font, (400, 48), "solve", "static", font_size=20))
|
||||||
|
self.assertEqual((width, height), (400, 48))
|
||||||
|
bar = bytes(caption.BAR)
|
||||||
|
text = [(x, y) for y, row in enumerate(rows) for x in range(width) if row[3 * x:3 * x + 3] != bar]
|
||||||
|
xs, ys = [p[0] for p in text], [p[1] for p in text]
|
||||||
|
self.assertLessEqual(abs((min(xs) + max(xs)) / 2 - 200), 1.5)
|
||||||
|
self.assertLessEqual(abs((min(ys) + max(ys)) / 2 - 24), 1.5)
|
||||||
|
self.assertIn(bytes(caption.TOOL), {row[3 * x:3 * x + 3] for row in rows for x in range(width)})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in new issue
Block a user