Files
44-Scale/docs/superpowers/plans/2026-07-28-e440-arc-dial.md

1202 lines
49 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# E440 Arc-Dial Scale Generator Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the M1730 linear-strip scale generator with an E440-style round-dial (arc) scale generator, matching the hand-drawn reference `e440.svg`, across the web app, the three deliverable SVG files, and all docs/infra naming.
**Architecture:** `web/app.py`'s `generate_svg()` is rewritten around polar geometry (a fixed pivot point, fixed rim radius, fixed angular sweep) instead of the old Cartesian geometry (fixed x-range, fixed y-baseline). Ticks and labels are drawn as `<line>`/`<text>`/path elements positioned "as if angle=0" (straight up from the pivot) and then given an SVG `rotate(angle, PIVOT_X, PIVOT_Y)` transform — this reuses the reference file's own rotation trick but around an explicit pivot, which avoids inverse-rotation math. All four scale mappings (linear/√/log/1÷x), both output modes (paths/text), custom labels, and unit markup are ported by substituting angle for x. `range_label` is replaced by independent `left_label`/`right_label` fields (each up to 2 lines). The three deliverable SVGs, README, CLAUDE.md, Dockerfile-adjacent docs, and k8s manifests are all renamed/rewritten from M1730 to E440.
**Tech Stack:** Python 3 / Flask, fontTools (`TTFont`, `SVGPathPen`) for glyph-to-path conversion — both already in `web/requirements.txt`, no new dependencies.
## Global Constraints
- No build, lint, or test tooling exists in this repo (per `CLAUDE.md`) — do not add pytest or any test framework. Verification steps in this plan use plain `python3 -c` / heredoc scripts with `assert`, run directly via Bash.
- All canvas/geometry constants below are measured directly from `e440.svg` (or computed from measurements) — use these exact values, don't re-derive or approximate differently.
- Coordinate convention: all constants are expressed in the **pre-layer-translate local frame** (the same frame `e440.svg`'s own elements use), with the SVG output wrapped in `<g transform="translate(-60.717741,-107.14126)">`. This mirrors the existing M1730 code's convention (`ZERO_X`, `MAX_X`, etc. were also pre-translate).
- Canvas size is fixed: `width="93.824249mm" height="57.215687mm" viewBox="0 0 93.824249 57.215687"`.
- Colors/strokes: ink `#1a1a1a` for ticks/labels/unit/corner text (matches reference), body fill `#f9f9f9` with `#1a1a1a` stroke at width `0.184`, pivot guide circle stroke `#b3b3b3` at width `0.184`, rim arc stroke `#000000` at width `0.484`.
- Geometry constants (local frame, mm):
- `PIVOT_X = 107.42845`, `PIVOT_Y = 162.62819`
- `R0 = 43.842751` (rim / inner-tick radius)
- `ANGLE_MIN = -45.667193`, `ANGLE_MAX = 45.667193` (degrees from vertical, clockwise-positive; low value on the left/negative side)
- `MAJOR_LEN = 4.7839203`, `MAJOR_W = 0.79658329`
- `MEDIUM_LEN = 3.1215858`, `MEDIUM_W = 0.37985462`
- `SMALL_LEN = 1.8121802`, `SMALL_W = 0.27478597`
- `LABEL_RADIUS = 49.862`
- `UNIT_X = 107.03512`, `UNIT_Y = 136.64594`
- `LEFT_X = 73.432251`, `LEFT_Y0 = 145.16051`
- `RIGHT_X = 142.4485`, `RIGHT_Y0 = 145.32773`
- `CORNER_LINE_SPACING = 4.4586`, `CORNER_FONT_SIZE = 3.56685`
- `PIVOT_CIRCLE_R = 1.7287594`
- The `half_height_unit` feature (and its UI toggle) is dropped — it existed only to solve a crowding problem specific to M1730's narrow side panel, which doesn't exist in the E440 layout.
- Point-on-circle formula (verified against the reference to within 0.003mm): `x = PIVOT_X + radius * sin(radians(angle_deg))`, `y = PIVOT_Y - radius * cos(radians(angle_deg))`.
---
### Task 1: Clean up fonts directory and register the DIN font labels/default
**Files:**
- Modify: `fonts/` (remove stray Windows metadata files)
- Modify: `web/app.py:19-26` (`_FONT_LABELS` dict), `web/app.py:51` (`_DEFAULT_FONT`)
**Interfaces:**
- Consumes: nothing new — `_load_fonts()` (`web/app.py:29-47`) already globs `*.ttf` in `fonts/` and builds `_FontData` per file; unchanged.
- Produces: `_DEFAULT_FONT` now resolves to the DIN regular font's key, used by every later task's default `font_key` argument.
- [ ] **Step 1: Remove stray Zone.Identifier files**
```bash
rm -f "fonts/alte-din-1451-mittelschrift.regular.ttf:Zone.Identifier" \
"fonts/alte-din-1451-mittelschrift.gepraegt.ttf:Zone.Identifier"
ls fonts/
```
Expected: only `.ttf` files remain (6 GOST-family + 2 DIN files).
- [ ] **Step 2: Install `defusedxml` into the venv for later verification steps**
This plan's well-formedness checks (Tasks 2 and 9) parse generated SVGs with `defusedxml` instead of the stdlib `xml.etree.ElementTree`, per this repo's security guidance (stdlib XML parsers are vulnerable to XXE/billion-laughs by default). It's not an `app.py` runtime dependency, just a verification-script tool, so it's installed into the venv but not added to `web/requirements.txt`.
```bash
cd /home/adebaumann/development/44-Scale
source .venv/bin/activate
pip install defusedxml
python3 -c "import defusedxml; print('OK')"
```
Expected: `OK`.
- [ ] **Step 3: Add DIN font display labels and change the default font**
In `web/app.py`, in the `_FONT_LABELS` dict, add two entries (key = filename stem, i.e. `Path('alte-din-1451-mittelschrift.regular.ttf').stem`):
```python
_FONT_LABELS = {
'GOST_2.30481_type_A': 'GOST Type A',
'GOST_2.30481_type_A_Bold': 'GOST Type A Bold',
'Gost2.30481TypeB': 'GOST Type B',
'ISOCPEUR': 'ISOCPEUR',
'SIEMENS_GOST_Type_A': 'Siemens GOST Type A',
'TGL_31034-1': 'TGL 31 034',
'alte-din-1451-mittelschrift.regular': 'Alte DIN 1451 Mittelschrift',
'alte-din-1451-mittelschrift.gepraegt': 'Alte DIN 1451 Mittelschrift (Gepraegt)',
}
```
Change the default font constant:
```python
_DEFAULT_FONT = 'alte-din-1451-mittelschrift.regular'
```
- [ ] **Step 4: Verify the font loads and is the default**
```bash
cd /home/adebaumann/development/44-Scale
python3 -c "
import sys; sys.path.insert(0, 'web')
from app import _FONTS, _DEFAULT_FONT
assert _DEFAULT_FONT in _FONTS, _FONTS.keys()
assert _FONTS[_DEFAULT_FONT].label == 'Alte DIN 1451 Mittelschrift'
print('OK', len(_FONTS), 'fonts loaded')
"
```
Expected: `OK 8 fonts loaded`.
- [ ] **Step 5: Commit**
```bash
git add fonts/ web/app.py
git commit -m "Add DIN 1451 fonts as default; drop stray Zone.Identifier files"
```
---
### Task 2: Rewrite `generate_svg()` skeleton around arc geometry (body + pivot + rim, no ticks/labels yet)
**Files:**
- Modify: `web/app.py` (constants block `web/app.py:53-77`, `generate_svg()` `web/app.py:320-488`, route handler `web/app.py:498-523`)
**Interfaces:**
- Consumes: `_FONTS`, `_DEFAULT_FONT`, `_markup_unit_svg`, `_text_to_paths`, `_fmt` (all unchanged, from earlier in the file).
- Produces: new `generate_svg(unit, min_val, max_val, left_label, right_label, big_ticks, small_ticks, label_count, use_paths=True, scale_type='linear', custom_labels=None, label_size=5.1126, font_key=None)` signature that later tasks extend. Produces `_point_at(radius, angle_deg)` helper used by Tasks 34.
- [ ] **Step 1: Replace the linear geometry constants with arc constants**
Replace `web/app.py:53-77` (from `# SVG absolute coordinate constants...` through the `_UNIT_BOX_*` block) with:
```python
# SVG absolute coordinate constants (1 user unit = 1 mm), pre-layer-translate
# local frame — the <g> wrapper applies translate(-60.717741,-107.14126).
PIVOT_X, PIVOT_Y = 107.42845, 162.62819
R0 = 43.842751 # rim / inner-tick radius
ANGLE_MIN, ANGLE_MAX = -45.667193, 45.667193 # degrees from vertical, clockwise-positive
MAJOR_LEN, MAJOR_W = 4.7839203, 0.79658329
MEDIUM_LEN, MEDIUM_W = 3.1215858, 0.37985462
SMALL_LEN, SMALL_W = 1.8121802, 0.27478597
LABEL_RADIUS = 49.862
UNIT_X, UNIT_Y = 107.03512, 136.64594
LEFT_X, LEFT_Y0 = 73.432251, 145.16051
RIGHT_X, RIGHT_Y0 = 142.4485, 145.32773
CORNER_LINE_SPACING = 4.4586
CORNER_FONT_SIZE = 3.56685
PIVOT_CIRCLE_R = 1.7287594
BODY_D = (
"m 62.162097,107.14126 c -0.800087,0 -1.444356,0.64428 -1.444356,1.44436 v 45.44839 "
"c 0,0.80008 0.644269,1.44436 1.444356,1.44436 h 7.345288 c 0.902634,0 1.233517,1.12706 "
"1.233517,1.12706 l 0.09043,3.03909 21.903573,-0.0904 2.226221,-5.98775 c 8.352594,-2.8884 "
"16.624974,-2.8884 24.817094,0 l 2.22674,5.98775 21.90357,0.0904 0.0904,-3.03909 c 0,0 "
"0.33088,-1.12706 1.23352,-1.12706 h 7.86567 c 0.80009,0 1.44384,-0.64428 1.44384,-1.44436 "
"v -45.44839 c 0,-0.80008 -0.64375,-1.44436 -1.44384,-1.44436 z m 29.619898,45.85354 "
"a 1.3990685,1.3990685 0 0 1 1.398881,1.39939 1.3990685,1.3990685 0 0 1 -1.398881,1.39888 "
"1.3990685,1.3990685 0 0 1 -1.399398,-1.39888 1.3990685,1.3990685 0 0 1 1.399398,-1.39939 "
"z m 31.220835,0 a 1.3990685,1.3990685 0 0 1 1.39888,1.39939 1.3990685,1.3990685 0 0 1 "
"-1.39888,1.39888 1.3990685,1.3990685 0 0 1 -1.3994,-1.39888 1.3990685,1.3990685 0 0 1 "
"1.3994,-1.39939 z"
)
_UNIT_BOX_X0, _UNIT_BOX_X1 = UNIT_X - 15.0, UNIT_X + 15.0
_UNIT_BOX_Y0, _UNIT_BOX_Y1 = UNIT_Y - 6.0, UNIT_Y + 6.0
_UNIT_MAX_FS = 9.46938021
_UNIT_SCALE_X = 1.0
def _point_at(radius: float, angle_deg: float):
th = math.radians(angle_deg)
return PIVOT_X + radius * math.sin(th), PIVOT_Y - radius * math.cos(th)
```
- [ ] **Step 2: Simplify `_markup_unit_svg` to drop the half-height box-override params**
Replace the `_markup_unit_svg` function (originally `web/app.py:248-268`) with:
```python
def _markup_unit_svg(unit: str, fd: _FontData, fill='#1a1a1a') -> str:
nodes = _parse_markup(unit)
x0, x1 = _UNIT_BOX_X0, _UNIT_BOX_X1
y0, y1 = _UNIT_BOX_Y0, _UNIT_BOX_Y1
xc, yc = (x0 + x1) / 2, (y0 + y1) / 2
w1 = _measure(nodes, 1.0, fd)
top1, bot1 = _vbounds(nodes, 1.0, fd)
fs = _UNIT_MAX_FS
if w1 > 0:
fs = min(fs, (x1 - x0) / (w1 * _UNIT_SCALE_X))
if bot1 - top1 > 0:
fs = min(fs, (y1 - y0) / (bot1 - top1))
w = _measure(nodes, fs, fd)
top, bot = _vbounds(nodes, fs, fd)
y_base = yc - (top + bot) / 2
parts, _ = _draw_nodes(nodes, xc - w / 2, y_base, fs, fill, fd)
cond = f'translate({xc:.5f},0) scale({_UNIT_SCALE_X},1) translate({-xc:.5f},0)'
return f'<g transform="{cond}">\n{chr(10).join(parts)}\n</g>'
```
(This drops the `box_y0`/`box_y1` parameters and the module-level `_UNIT_BOX_HALF_Y0` constant — search `web/app.py` for `_UNIT_BOX_HALF_Y0` and `half_height_unit` and delete every reference; they no longer exist anywhere after this task.)
- [ ] **Step 3: Delete the now-unused `_unit_font_size` function**
Delete the `_unit_font_size` function entirely (originally `web/app.py:271-275`) — after Task 5 the unit is always rendered via `_markup_unit_svg`, so this fast-path helper becomes dead code.
- [ ] **Step 4: Replace `generate_svg()` with the new skeleton**
Replace the whole `generate_svg` function (originally `web/app.py:320-488`) with:
```python
def generate_svg(unit, min_val, max_val, left_label, right_label, big_ticks, small_ticks,
label_count, use_paths=True, scale_type='linear', custom_labels=None,
label_size=5.1126, font_key=None):
fd = _FONTS.get(font_key) or _FONTS.get(_DEFAULT_FONT) or next(iter(_FONTS.values()))
font_face = (
'<defs><style>'
f'@font-face {{font-family:"{fd.label}";'
f'src:url("data:font/truetype;base64,{fd.b64}") format("truetype");}}'
'</style></defs>'
)
parts = [
'<?xml version="1.0" encoding="UTF-8" standalone="no"?>',
'<svg width="93.824249mm" height="57.215687mm"'
' viewBox="0 0 93.824249 57.215687" version="1.1"'
' xmlns="http://www.w3.org/2000/svg">',
]
if use_paths:
parts.append(' <g transform="translate(-60.717741,-107.14126)">')
else:
parts += [font_face, ' <g transform="translate(-60.717741,-107.14126)">']
parts.append(
f' <path style="fill:#f9f9f9;stroke:#1a1a1a;stroke-width:0.184;'
f'stroke-linecap:round;stroke-miterlimit:10" d="{BODY_D}" />'
)
p0 = _point_at(R0, ANGLE_MIN)
pmid = _point_at(R0, 0.0)
p1 = _point_at(R0, ANGLE_MAX)
parts.append(
f' <path style="fill:none;stroke:#000000;stroke-width:0.484;'
f'stroke-linecap:round;stroke-miterlimit:10"'
f' d="M {p0[0]:.5f},{p0[1]:.5f} A {R0},{R0} 0 0 1 {pmid[0]:.5f},{pmid[1]:.5f}'
f' A {R0},{R0} 0 0 1 {p1[0]:.5f},{p1[1]:.5f}" />'
)
parts.append(
f' <circle style="fill:none;stroke:#b3b3b3;stroke-width:0.184;'
f'stroke-linecap:round;stroke-miterlimit:10"'
f' cx="{PIVOT_X:.5f}" cy="{PIVOT_Y:.5f}" r="{PIVOT_CIRCLE_R}" />'
)
parts += [' </g>', '</svg>']
return '\n'.join(parts)
```
(Ticks, labels, unit, and corner text are added by Tasks 36; this step must produce a valid, renderable SVG containing just the case body, rim arc, and pivot guide circle.)
- [ ] **Step 5: Update the `/generate` Flask route to the new parameter set**
Replace the `/generate` route body (originally `web/app.py:498-523`) with:
```python
@app.route('/generate', methods=['POST'])
def generate():
unit = request.form.get('unit', '%')
min_val = float(request.form.get('min', 0))
max_val = float(request.form.get('max', 100))
left_label = request.form.get('left_label', '')
right_label = request.form.get('right_label', '')
big_ticks = max(1, min(50, int(request.form.get('big_ticks', 10))))
small_ticks = max(0, min(20, int(request.form.get('small_ticks', 4))))
label_count = max(2, int(request.form.get('label_count', 6)))
use_paths = request.form.get('output_mode', 'paths') == 'paths'
scale_type = request.form.get('scale_type', 'linear')
font_key = request.form.get('font', _DEFAULT_FONT)
if request.form.get('max_is_inf') == '1':
max_val = math.inf
custom_labels = [s.strip() for s in request.form.get('labels', '').split(',') if s.strip()] or None
label_size = max(0.5, min(25.0, float(request.form.get('label_size', 5.1126))))
try:
svg = generate_svg(unit, min_val, max_val, left_label, right_label, big_ticks,
small_ticks, label_count, use_paths, scale_type, custom_labels,
label_size, font_key)
except ValueError as err:
return Response(str(err), status=400, mimetype='text/plain')
return Response(svg, mimetype='image/svg+xml')
```
- [ ] **Step 6: Verify the skeleton renders a valid SVG**
```bash
cd /home/adebaumann/development/44-Scale
python3 -c "
import sys; sys.path.insert(0, 'web')
from app import generate_svg
svg = generate_svg('uA', 0, 50, 'Text\nLeft', 'Text\nRight', 5, 9, 6)
assert svg.startswith('<?xml')
assert 'viewBox=\"0 0 93.824249 57.215687\"' in svg
assert 'translate(-60.717741,-107.14126)' in svg
assert 'cx=\"107.42845\"' in svg
from defusedxml import ElementTree as ET
ET.fromstring(svg)
print('OK, valid XML,', len(svg), 'bytes')
"
```
Expected: `OK, valid XML, ... bytes` with no exception. (Requires `defusedxml``pip install defusedxml` if not already available.)
- [ ] **Step 7: Commit**
```bash
git add web/app.py
git commit -m "Rewrite generate_svg skeleton around arc geometry (body/rim/pivot only)"
```
---
### Task 3: Implement tick generation (3-tier, all 4 scale mappings)
**Files:**
- Modify: `web/app.py` (inside `generate_svg`, after the pivot-circle `parts.append` from Task 2 Step 4, before `parts += [' </g>', '</svg>']`)
**Interfaces:**
- Consumes: `_point_at`, `R0`, `ANGLE_MIN`, `ANGLE_MAX`, `MAJOR_LEN/W`, `MEDIUM_LEN/W`, `SMALL_LEN/W` from Task 2.
- Produces: `big_angles` (list[float], length `big_ticks + 1`) and `big_vals` (list[float], same length) — consumed by Task 4's label loop.
- [ ] **Step 1: Add the mapping functions (`value_to_angle` / `major_value`) and tick-line helper**
Insert immediately after the `fd = _FONTS.get(...)` line inside `generate_svg`:
```python
if scale_type == 'log':
if min_val <= 0 or max_val <= 0:
raise ValueError("Logarithmic scale requires min and max values greater than 0.")
lmin, lmax = math.log(min_val), math.log(max_val)
lspan = lmax - lmin
if lspan == 0:
raise ValueError("Logarithmic scale requires min and max values to differ.")
def value_to_angle(v):
return ANGLE_MIN + (ANGLE_MAX - ANGLE_MIN) * (math.log(v) - lmin) / lspan
def major_value(i):
return min_val * (max_val / min_val) ** (i / big_ticks)
elif scale_type == 'sqrt':
if min_val < 0:
raise ValueError("Square-root scale requires min value ≥ 0.")
span = max_val - min_val
if span <= 0:
raise ValueError("Square-root scale requires max value greater than min.")
def value_to_angle(v):
return ANGLE_MIN + (ANGLE_MAX - ANGLE_MIN) * math.sqrt((v - min_val) / span)
def major_value(i):
return min_val + span * (i / big_ticks) ** 2
elif scale_type == 'reciprocal':
if min_val <= 0:
raise ValueError("Reciprocal scale requires min and max values greater than 0.")
if not math.isinf(max_val) and max_val <= min_val:
raise ValueError("Reciprocal scale requires max value greater than min.")
if math.isinf(max_val):
def value_to_angle(v):
if math.isinf(v):
return ANGLE_MIN
return ANGLE_MIN + (ANGLE_MAX - ANGLE_MIN) * min_val / v
def major_value(i):
if i == 0:
return math.inf
return min_val * big_ticks / i
else:
if max_val <= 0:
raise ValueError("Reciprocal scale requires min and max values greater than 0.")
k = 1.0 / min_val - 1.0 / max_val
def value_to_angle(v):
return ANGLE_MIN + (ANGLE_MAX - ANGLE_MIN) * (1.0 / v - 1.0 / max_val) / k
def major_value(i):
return 1.0 / (1.0 / max_val + i / big_ticks * k)
else:
span = max_val - min_val
def value_to_angle(v):
if span == 0:
return ANGLE_MIN
return ANGLE_MIN + (ANGLE_MAX - ANGLE_MIN) * (v - min_val) / span
def major_value(i):
return min_val + span * i / big_ticks
big_interval_angle = (ANGLE_MAX - ANGLE_MIN) / big_ticks
big_angles = [ANGLE_MIN + i * big_interval_angle for i in range(big_ticks + 1)]
big_vals = [major_value(i) for i in range(big_ticks + 1)]
if custom_labels:
label_count = max(1, min(len(custom_labels), big_ticks + 1))
custom_labels = custom_labels[:label_count]
else:
label_count = max(2, min(label_count, big_ticks + 1))
if label_count == 1:
label_indices = [0]
else:
label_indices = [round(k * big_ticks / (label_count - 1)) for k in range(label_count)]
def _tick_line(angle_deg, length, width):
x = PIVOT_X
y1 = PIVOT_Y - R0
y2 = PIVOT_Y - R0 - length
return (
f' <line x1="{x:.5f}" y1="{y1:.5f}" x2="{x:.5f}" y2="{y2:.5f}"'
f' style="stroke:#1a1a1a;stroke-width:{width:.5f};stroke-linecap:butt"'
f' transform="rotate({angle_deg:.5f},{PIVOT_X:.5f},{PIVOT_Y:.5f})" />'
)
```
- [ ] **Step 2: Draw the ticks**
Insert immediately after the pivot-circle `parts.append(...)` block (still before `parts += [' </g>', '</svg>']`):
```python
for angle in big_angles:
parts.append(_tick_line(angle, MAJOR_LEN, MAJOR_W))
if small_ticks > 0:
sub = small_ticks + 1
medium_j = sub // 2 if small_ticks % 2 == 1 else None
for i in range(big_ticks):
for j in range(1, small_ticks + 1):
if scale_type == 'sqrt':
angle = big_angles[i] + (j / sub) ** 2 * (big_angles[i + 1] - big_angles[i])
elif scale_type == 'reciprocal' or math.isinf(big_vals[i]):
angle = big_angles[i] + (j / sub) * (big_angles[i + 1] - big_angles[i])
else:
v = big_vals[i] + j * (big_vals[i + 1] - big_vals[i]) / sub
angle = value_to_angle(v)
length, width = (MEDIUM_LEN, MEDIUM_W) if j == medium_j else (SMALL_LEN, SMALL_W)
parts.append(_tick_line(angle, length, width))
```
- [ ] **Step 3: Verify tick angles for the reference case (050, 5 major, 9 minor)**
```bash
cd /home/adebaumann/development/44-Scale
python3 -c "
import sys; sys.path.insert(0, 'web')
from app import generate_svg
svg = generate_svg('uA', 0, 50, '', '', 5, 9, 6)
# major ticks at -45.66719, -27.40032, -9.13344, 9.13344, 27.40032, 45.66719
for a in ['-45.66719', '-27.40032', '-9.13344', '9.13344', '27.40032', '45.66719']:
assert f'rotate({a},' in svg, a
# medium ticks (values 5,15,25,35,45) at the interval midpoints
for a in ['-36.53375', '-18.26688', '0.00000', '18.26688', '36.53375']:
assert f'rotate({a},' in svg, a
assert svg.count('<line') == 6 + 5 + 40 # 6 major + 5 medium + 40 small (8 per interval * 5)
print('OK')
"
```
Expected: `OK` with no assertion error.
- [ ] **Step 4: Commit**
```bash
git add web/app.py
git commit -m "Implement arc tick generation with 3 tiers across all scale mappings"
```
---
### Task 4: Implement numeric label generation
**Files:**
- Modify: `web/app.py` (inside `generate_svg`, after the tick-drawing block from Task 3, before `parts += [' </g>', '</svg>']`)
**Interfaces:**
- Consumes: `big_angles`, `big_vals`, `label_indices`, `custom_labels`, `label_size`, `_point_at`, `LABEL_RADIUS`, `_text_to_paths`, `_fmt` — all already available inside `generate_svg`.
- Produces: nothing new consumed by later tasks (labels are a leaf feature).
- [ ] **Step 1: Draw the labels**
Insert immediately after the tick small-ticks loop from Task 3 Step 2:
```python
lx, ly = PIVOT_X, PIVOT_Y - LABEL_RADIUS
for k, idx in enumerate(label_indices):
angle = big_angles[idx]
label = custom_labels[k] if custom_labels else _fmt(big_vals[idx])
wrap = f'rotate({angle:.5f},{PIVOT_X:.5f},{PIVOT_Y:.5f})'
if use_paths:
parts.append(' ' + _text_to_paths(label, lx, ly, label_size, fd,
anchor='middle', wrap_transform=wrap))
else:
parts.append(
f' <text style="font-size:{label_size:.5f}px;font-family:\'{fd.label}\',monospace;fill:#1a1a1a"'
f' text-anchor="middle" x="{lx:.5f}" y="{ly:.5f}" transform="{wrap}">{escape(label)}</text>'
)
```
- [ ] **Step 2: Verify labels for the reference case**
```bash
cd /home/adebaumann/development/44-Scale
python3 -c "
import sys; sys.path.insert(0, 'web')
from app import generate_svg
svg = generate_svg('uA', 0, 50, '', '', 5, 9, 6, use_paths=False)
for val, angle in [('0','-45.66719'), ('10','-27.40032'), ('20','-9.13344'),
('30','9.13344'), ('40','27.40032'), ('50','45.66719')]:
assert f'rotate({angle},' in svg and f'>{val}</text>' in svg, (val, angle)
print('OK')
svg2 = generate_svg('uA', 0, 50, '', '', 5, 9, 3, custom_labels=['Lo','Mid','Hi'], use_paths=False)
for val in ('Lo', 'Mid', 'Hi'):
assert f'>{val}</text>' in svg2, val
print('OK custom labels')
"
```
Expected: `OK` then `OK custom labels`.
- [ ] **Step 3: Commit**
```bash
git add web/app.py
git commit -m "Implement rotated numeric label generation for the arc scale"
```
---
### Task 5: Implement the unit label
**Files:**
- Modify: `web/app.py` (inside `generate_svg`, after the label loop from Task 4)
**Interfaces:**
- Consumes: `_markup_unit_svg` (Task 2 Step 2), `unit`, `fd`.
- Produces: nothing new consumed later.
- [ ] **Step 1: Draw the unit label**
Insert immediately after the label loop from Task 4 Step 1:
```python
parts.append(' ' + _markup_unit_svg(unit, fd))
```
- [ ] **Step 2: Verify plain and markup units render**
```bash
cd /home/adebaumann/development/44-Scale
python3 -c "
import sys; sys.path.insert(0, 'web')
from app import generate_svg
svg = generate_svg('uA', 0, 50, '', '', 5, 9, 6)
assert svg.count('<path') >= 2 # body path + rim path + at least glyph paths
svg2 = generate_svg('cm^2', 0, 50, '', '', 5, 9, 6)
assert svg2 != svg
print('OK')
"
```
Expected: `OK`.
- [ ] **Step 3: Commit**
```bash
git add web/app.py
git commit -m "Wire up unit label rendering via markup-aware helper"
```
---
### Task 6: Implement left/right corner labels; fix `_text_to_paths` end-anchor support
**Files:**
- Modify: `web/app.py` (`_text_to_paths` function, originally `web/app.py:278-305`; and inside `generate_svg`, after the unit label from Task 5)
**Interfaces:**
- Consumes: `left_label`, `right_label` (strings, already threaded through the function signature and route since Task 2), `LEFT_X/Y0`, `RIGHT_X/Y0`, `CORNER_LINE_SPACING`, `CORNER_FONT_SIZE`.
- Produces: nothing new — this is the last content block before `</g></svg>`.
- [ ] **Step 1: Add `end` anchor support to `_text_to_paths`**
`_text_to_paths` currently only handles `anchor == 'middle'` for width-based offset (`start` is the implicit default with no offset). Right-anchored corner text needs a third case. Replace the anchor-handling block at the top of `_text_to_paths`:
```python
if anchor == 'middle':
total_w = sum(
fd.hmtx.get(fd.cmap.get(ord(ch)), (0, 0))[0] * s
for ch in text if fd.cmap.get(ord(ch))
)
x -= total_w / 2
```
with:
```python
if anchor in ('middle', 'end'):
total_w = sum(
fd.hmtx.get(fd.cmap.get(ord(ch)), (0, 0))[0] * s
for ch in text if fd.cmap.get(ord(ch))
)
x -= total_w / 2 if anchor == 'middle' else total_w
```
- [ ] **Step 2: Draw the corner labels**
Insert immediately after the unit-label line from Task 5 Step 1:
```python
left_lines = left_label.split('\n')[:2]
right_lines = right_label.split('\n')[:2]
for i, line in enumerate(left_lines):
if not line:
continue
y = LEFT_Y0 + i * CORNER_LINE_SPACING
if use_paths:
parts.append(' ' + _text_to_paths(line, LEFT_X, y, CORNER_FONT_SIZE, fd, anchor='start'))
else:
parts.append(
f' <text style="font-size:{CORNER_FONT_SIZE:.5f}px;font-family:\'{fd.label}\',monospace;fill:#1a1a1a"'
f' x="{LEFT_X:.5f}" y="{y:.5f}">{escape(line)}</text>'
)
for i, line in enumerate(right_lines):
if not line:
continue
y = RIGHT_Y0 + i * CORNER_LINE_SPACING
if use_paths:
parts.append(' ' + _text_to_paths(line, RIGHT_X, y, CORNER_FONT_SIZE, fd, anchor='end'))
else:
parts.append(
f' <text style="font-size:{CORNER_FONT_SIZE:.5f}px;font-family:\'{fd.label}\',monospace;fill:#1a1a1a"'
f' text-anchor="end" x="{RIGHT_X:.5f}" y="{y:.5f}">{escape(line)}</text>'
)
```
- [ ] **Step 3: Verify corner labels (2-line, start/end anchored) render correctly**
```bash
cd /home/adebaumann/development/44-Scale
python3 -c "
import sys; sys.path.insert(0, 'web')
from app import generate_svg
svg = generate_svg('uA', 0, 50, 'Text\nLeft', 'Text\nRight', 5, 9, 6, use_paths=False)
assert '>Text</text>' in svg and '>Left</text>' in svg and '>Right</text>' in svg
assert 'text-anchor=\"end\"' in svg
svg_one_line = generate_svg('uA', 0, 50, 'OnlyLine', '', 5, 9, 6, use_paths=False)
assert '>OnlyLine</text>' in svg_one_line
print('OK')
"
```
Expected: `OK`.
- [ ] **Step 4: Commit**
```bash
git add web/app.py
git commit -m "Implement left/right corner labels; add end-anchor support to text-to-paths"
```
---
### Task 7: Update the web form (`web/templates/index.html`)
**Files:**
- Modify: `web/templates/index.html`
**Interfaces:**
- Consumes: the `/generate` route's new form fields (`left_label`, `right_label`) from Task 2 Step 5.
- Produces: nothing consumed by later tasks — this is the UI leaf.
- [ ] **Step 1: Update page title and header wordmark**
Replace:
```html
<title>M1730 Scale Generator — Baumann Works</title>
```
with:
```html
<title>E440 Scale Generator — Baumann Works</title>
```
Replace:
```html
<span class="wordmark-sub">M1730 Scale Generator</span>
```
with:
```html
<span class="wordmark-sub">E440 Scale Generator</span>
```
- [ ] **Step 2: Replace the Unit/Range-label row with Unit/Left-label/Right-label, dropping the half-height toggle**
Replace the entire `Labels` section block:
```html
<div class="section">
<div class="section-eye">Labels</div>
<div class="row row-2">
<div class="field">
<label>Unit</label>
<input name="unit" type="text" value="%" required>
<span style="font-family:var(--f-mono);font-size:0.65rem;color:var(--muted)">Markup: cm^2 · H_2O · {1/2} fraction · nests, e.g. {m/s^2}</span>
<div style="display:flex;align-items:center;gap:0.5rem;margin-top:0.2rem">
<div class="mode-toggle" role="group" aria-label="Unit height">
<button type="button" class="mode-btn active" data-unit-height="normal">Full height</button>
<button type="button" class="mode-btn" data-unit-height="half"
title="Restrict unit label to the upper half of the panel — use when numeric labels visually crowd the unit">Half height</button>
</div>
<input type="hidden" name="half_height_unit" id="half-height-unit" value="0">
</div>
</div>
<div class="field">
<label>Range label</label>
<input name="range_label" type="text" value="5mA" required>
</div>
</div>
</div>
```
with:
```html
<div class="section">
<div class="section-eye">Labels</div>
<div class="row row-2">
<div class="field">
<label>Unit</label>
<input name="unit" type="text" value="uA" required>
<span style="font-family:var(--f-mono);font-size:0.65rem;color:var(--muted)">Markup: cm^2 · H_2O · {1/2} fraction · nests, e.g. {m/s^2}</span>
</div>
</div>
<div class="row row-2" style="margin-top:1.25rem">
<div class="field">
<label>Left label (up to 2 lines)</label>
<textarea name="left_label" rows="2" style="background:var(--white);border:1px solid var(--rule);border-radius:1px;padding:0.5rem 0.7rem;font-family:var(--f-body);font-size:0.925rem;color:var(--ink);width:100%;outline:none;resize:vertical">Text
Left</textarea>
</div>
<div class="field">
<label>Right label (up to 2 lines)</label>
<textarea name="right_label" rows="2" style="background:var(--white);border:1px solid var(--rule);border-radius:1px;padding:0.5rem 0.7rem;font-family:var(--f-body);font-size:0.925rem;color:var(--ink);width:100%;outline:none;resize:vertical">Text
Right</textarea>
</div>
</div>
</div>
```
- [ ] **Step 3: Update the default Label size value to match the new reference default**
Replace:
```html
<input name="label_size" type="number" step="any" min="0.5" max="25" value="9" required>
```
with:
```html
<input name="label_size" type="number" step="any" min="0.5" max="25" value="5.1126" required>
```
- [ ] **Step 4: Remove the now-dead unit-height JS handler and download filename**
Delete this block from the `<script>`:
```html
document.querySelectorAll('[data-unit-height]').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('[data-unit-height]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.getElementById('half-height-unit').value = btn.dataset.unitHeight === 'half' ? '1' : '0';
doGenerate();
});
});
```
Replace:
```html
download: 'M1730-Scale.svg',
```
with:
```html
download: 'E440-Scale.svg',
```
- [ ] **Step 5: Update the source link and version tag**
Replace:
```html
<span class="version-tag">v{{ version }} — <a href="https://git.baumann.gr/adebaumann/M1730-Scale" target="_blank" rel="noopener">Source</a></span>
```
with:
```html
<span class="version-tag">v{{ version }} — <a href="https://git.baumann.gr/adebaumann/E440-Scale" target="_blank" rel="noopener">Source</a></span>
```
- [ ] **Step 6: Run the app locally and verify the form generates a scale**
```bash
cd /home/adebaumann/development/44-Scale
source .venv/bin/activate 2>/dev/null || true
(cd web && python app.py &)
sleep 2
curl -s -X POST http://localhost:5000/generate \
-F unit=uA -F min=0 -F max=50 -F left_label=$'Text\nLeft' -F right_label=$'Text\nRight' \
-F big_ticks=5 -F small_ticks=9 -F label_count=6 -F output_mode=paths -F scale_type=linear \
-F label_size=5.1126 -F font=alte-din-1451-mittelschrift.regular \
| head -c 300
echo
pkill -f "python app.py" || true
```
Expected: response starts with `<?xml version="1.0"...` and contains no error text. Then also open `http://localhost:5000/` in a browser (or note that a headless check isn't sufficient) to visually confirm the form renders and the light-table preview shows an arc dial, not a strip.
- [ ] **Step 7: Commit**
```bash
git add web/templates/index.html
git commit -m "Update web form for arc-dial fields: unit, left/right labels"
```
---
### Task 8: Manual visual verification against the reference
**Files:** none modified — verification only.
**Interfaces:**
- Consumes: the fully working generator from Tasks 17.
- [ ] **Step 1: Generate the reference-matching scale and save it**
```bash
cd /home/adebaumann/development/44-Scale
python3 -c "
import sys; sys.path.insert(0, 'web')
from app import generate_svg
svg = generate_svg('uA', 0, 50, 'Text\nLeft', 'Text\nRight', 5, 9, 6,
use_paths=False, font_key='alte-din-1451-mittelschrift.regular')
open('/tmp/e440-generated-check.svg', 'w').write(svg)
print('written')
"
```
- [ ] **Step 2: Open both files in a browser or Inkscape and compare side-by-side**
```bash
xdg-open /home/adebaumann/development/44-Scale/e440.svg 2>/dev/null || true
xdg-open /tmp/e440-generated-check.svg 2>/dev/null || true
```
Check specifically:
- Tick marks form a symmetric fan matching the reference's angular spread (not compressed/stretched/mirrored).
- The rim arc (`<path>` with stroke `#000000`) follows the same curve as the ticks' base, with correct orientation (not flipped inside-out — if the arc bulges the wrong way, its sweep-flag in Task 2 Step 4 needs flipping from `0 0 1` to `0 0 0`).
- Numbers 050 sit just outside the tick tips, tilted to match their tick's angle, same reading direction as the reference (not upside-down).
- `uA` sits centered above the arc; `Text`/`Left` and `Text`/`Right` sit in the bottom corners matching the reference's placeholder positions.
If the rim arc orientation is wrong, fix it now in `web/app.py` (Task 2 Step 4's sweep-flag) and re-run Step 1.
- [ ] **Step 3: No commit for this task** (verification only; any fixes found get their own commit against the task that introduced the bug).
---
### Task 9: Produce the three deliverable SVG files; remove the old M1730 files
**Files:**
- Create: `E440-Scale_template.svg` (renamed/tidied from `e440.svg`)
- Create: `E440-Scale_paths.svg`
- Create: `E440-Scale_text.svg`
- Delete: `e440.svg`, `M1730-Scale_template.svg`, `M1730-Scale_paths.svg`, `M1730-Scale_text.svg`
**Interfaces:**
- Consumes: `generate_svg()` from Tasks 16, run with the reference parameters (unit `uA`, 050, 5 major/9 minor/6 labels, left `Text\nLeft`, right `Text\nRight`, font `alte-din-1451-mittelschrift.regular`).
- [ ] **Step 1: Create the template file**
```bash
cd /home/adebaumann/development/44-Scale
git mv e440.svg E440-Scale_template.svg
```
Edit `E440-Scale_template.svg`: change `sodipodi:docname="e440.svg"` to `sodipodi:docname="E440-Scale.svg"`.
- [ ] **Step 2: Generate the paths (outline) variant**
```bash
cd /home/adebaumann/development/44-Scale
python3 -c "
import sys; sys.path.insert(0, 'web')
from app import generate_svg
svg = generate_svg('uA', 0, 50, 'Text\nLeft', 'Text\nRight', 5, 9, 6,
use_paths=True, font_key='alte-din-1451-mittelschrift.regular')
open('E440-Scale_paths.svg', 'w').write(svg + '\n')
"
```
- [ ] **Step 3: Generate the text (embedded-font) variant**
```bash
cd /home/adebaumann/development/44-Scale
python3 -c "
import sys; sys.path.insert(0, 'web')
from app import generate_svg
svg = generate_svg('uA', 0, 50, 'Text\nLeft', 'Text\nRight', 5, 9, 6,
use_paths=False, font_key='alte-din-1451-mittelschrift.regular')
open('E440-Scale_text.svg', 'w').write(svg + '\n')
"
```
- [ ] **Step 4: Delete the old M1730 files**
```bash
cd /home/adebaumann/development/44-Scale
git rm M1730-Scale_template.svg M1730-Scale_paths.svg M1730-Scale_text.svg
```
- [ ] **Step 5: Verify all three new files are well-formed XML**
```bash
cd /home/adebaumann/development/44-Scale
python3 -c "
from defusedxml.ElementTree import parse
for f in ('E440-Scale_template.svg', 'E440-Scale_paths.svg', 'E440-Scale_text.svg'):
parse(f)
print(f, 'OK')
"
```
Expected: `OK` printed for all three files. (Requires `defusedxml``pip install defusedxml` if it's not already available; stdlib XML parsers are avoided here per repo security guidance since they're vulnerable to XXE/billion-laughs by default, even though these particular files are locally generated and trusted.)
- [ ] **Step 6: Commit**
```bash
git add E440-Scale_template.svg E440-Scale_paths.svg E440-Scale_text.svg
git commit -m "Replace M1730 deliverable SVGs with generated E440 template/paths/text files"
```
---
### Task 10: Rebrand infra and docs (Dockerfile check, k8s, README.md, CLAUDE.md, VERSION bump)
**Files:**
- Modify: `k8s/deployment.yaml`, `k8s/ingress.yaml`, `k8s/service.yaml`
- Modify: `README.md`, `CLAUDE.md`
- Modify: `web/app.py:12` (`VERSION`)
**Interfaces:** none — this is the final documentation/infra pass.
- [ ] **Step 1: Bump the app version**
In `web/app.py`, change:
```python
VERSION = "0.87"
```
to:
```python
VERSION = "1.0"
```
- [ ] **Step 2: Rename k8s resources from m1730 to e440**
In `k8s/deployment.yaml`, replace every occurrence of `m1730-generator` with `e440-generator`, and the image tag:
```yaml
image: git.baumann.gr/adebaumann/m1730-generator:0.87
```
with:
```yaml
image: git.baumann.gr/adebaumann/e440-generator:1.0
```
In `k8s/ingress.yaml`, replace every occurrence of `m1730-strip-prefix` with `e440-strip-prefix`, `m1730-generator` with `e440-generator`, and `/M1730-generator` with `/E440-generator`.
In `k8s/service.yaml`, replace every occurrence of `m1730-generator` with `e440-generator`.
- [ ] **Step 3: Verify no `m1730`/`M1730` strings remain in k8s manifests**
```bash
cd /home/adebaumann/development/44-Scale
grep -ri m1730 k8s/ && echo "FOUND STALE REFS" || echo "clean"
```
Expected: `clean`.
- [ ] **Step 4: Rewrite `README.md`**
Replace the full contents of `README.md` with:
```markdown
# E440-Scale
SVG scale artwork and web generator for E440-style round-dial analog panel
meters. Scales are typeset in **Alte DIN 1451 Mittelschrift**.
## Repository layout
```
E440-Scale_template.svg Inkscape working file (case body, tick marks, labels)
E440-Scale_paths.svg Paths-only export — text converted to outlines
E440-Scale_text.svg Live-text export — embedded font, editable labels
fonts/ TTF fonts (required for Docker build)
web/ Flask web generator
k8s/ Kubernetes manifests
Dockerfile Container build
```
## SVG files
The canvas is **93.824249 × 57.215687 mm**. The coordinate origin is
offset: layer 1 carries `transform="translate(-60.717741,-107.14126)"`, so
absolute SVG coordinates begin around x=61, y=107.
The dial's case geometry — body outline, mounting ears, pivot point
(local `107.42845, 162.62819`), rim radius (`43.842751` mm), and angular
sweep (`±45.667°` from vertical) — is fixed across every generated scale.
Only the tick marks, numeric labels, unit label, and corner text vary.
| File | Purpose |
|---|---|
| `E440-Scale_template.svg` | Master Inkscape file. Edit this one. |
| `E440-Scale_paths.svg` | All text converted to outlines. Use for cutting plotters, laser engravers, or any workflow where fonts must not be embedded as live text. |
| `E440-Scale_text.svg` | Live `<text>` nodes with the font embedded as base64. Smaller file; requires an SVG viewer that supports embedded fonts. |
Open files in **Inkscape** (developed with v1.4.3). There is no build step
— edit the SVG directly or use the web generator.
## Web generator
A Flask app that generates arc-dial scale SVGs from a form. Supports two
output modes switchable in the UI:
- **Paths** — glyphs rendered as outlines via fontTools; opens cleanly in
Inkscape, Illustrator, and cutting software
- **Text** — live `<text>` elements with the selected font embedded as
base64
### Run locally
```bash
source .venv/bin/activate
cd web && python app.py # http://localhost:5000
```
Fonts are loaded at startup from the `fonts/` directory in the repo root.
### Form parameters
| Field | Description |
|---|---|
| Unit | Symbol shown centered above the arc (e.g. `uA`, `V`, `%`). Supports inline markup — see below |
| Left label / Right label | Up to 2 lines each, placed in the bottom-left/bottom-right corners of the dial |
| Mapping | **Linear**, **√** (square root), **Log**, or **1/x** (reciprocal). Major ticks are always evenly spaced angularly; the mapping controls their values and how minor ticks are placed within each interval. |
| Min / Max | Numeric range of the scale |
| Major intervals | Number of major tick intervals |
| Minor per interval | Minor ticks between each pair of major ticks. When odd, the exact midpoint subdivision is drawn as a taller "medium" tick, matching the reference dial's 3-tier tick scheme |
| Labels | Number of numeric labels; Labels 1 should divide evenly into Major intervals |
| Custom labels | Optional comma-separated labels that override the calculated numbers |
| Label size (mm) | Font size of the numeric labels, in millimetres (default `5.1126`) |
### Scale mappings
| Mapping | Major tick values | Minor tick placement | Constraints |
|---|---|---|---|
| Linear | Evenly spaced | Uniform | None |
| √ | Quadratic (0, 1, 4, 9 … × max) — compresses low values | Crowded toward the low end of each interval | Min ≥ 0 |
| Log | Geometric (min × r⁰, r¹, r² …) — each interval spans the same ratio | Crowded toward the high end of each interval | Min and Max > 0 |
| 1/x | Harmonic (values decrease left → right, e.g. ∞, 10, 5, 2, 1) — compresses high values | Uniform (in angle space, i.e. proportional to 1/v) | Min and Max > 0; Max may be ∞ |
### Unit markup
The Unit field accepts a lightweight shorthand (no LaTeX engine — everything
is drawn as glyph paths and auto-sized to fit above the arc):
| Markup | Result |
|---|---|
| `cm^2` | superscript (`^{...}` for multiple characters) |
| `H_2O` | subscript (`_{...}` for multiple characters) |
| `{1/2}` | stacked fraction with a horizontal rule; a bare `/` stays inline |
Constructs nest: `{m/s^2}` is a fraction with a superscript in the
denominator, `x^{1/2}` a superscripted fraction.
## Container
```bash
docker build -t e440-scale:latest .
docker run -p 5000:5000 e440-scale:latest
```
Fonts are loaded from `fonts/` in the repo root; they are bundled into the
image at build time.
The production image is at `git.baumann.gr/adebaumann/e440-generator`.
## Kubernetes
Manifests in `k8s/` deploy to the `works` namespace with a Traefik v2
IngressRoute at `works.baumann.gr/E440-generator`.
```bash
# Managed by ArgoCD — push to git to deploy
git push
```
Resources: namespace, deployment (1 replica, gunicorn), ClusterIP service,
Traefik IngressRoute + stripPrefix middleware.
## License
GPL-3.0 — see [LICENSE](LICENSE).
```
- [ ] **Step 5: Rewrite `CLAUDE.md`**
Replace the full contents of `CLAUDE.md` with:
```markdown
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
SVG scale artwork for E440-style round-dial analog meters. The scales are designed in Inkscape (v1.4.3) using **Alte DIN 1451 Mittelschrift**, a German technical drafting standard typeface.
The canvas is **93.824249 mm × 57.215687 mm**. The coordinate origin is offset: `transform="translate(-60.717741,-107.14126)"` is applied to `layer1` in all files, so absolute SVG coordinates start around x=61, y=107 rather than 0,0.
## File Roles
The three SVG files represent layers of the same design, intended to be combined or used separately:
- **`E440-Scale_template.svg`** — Inkscape working template. Contains the case body path (with mounting ears and rim cutout), the pivot guide circle, tick marks, numeric labels `0``50`, the `uA` unit label, and `Text`/`Left` and `Text`/`Right` corner label placeholders.
- **`E440-Scale_paths.svg`** — Paths-only version: all tick marks and text as `<path>`/`<line>` elements, no live text. Used for cutting/printing workflows where fonts must not be embedded as live text.
- **`E440-Scale_text.svg`** — Text-overlay version: tick mark lines plus live `<text>` elements for numeric/unit/corner labels, font embedded as base64.
## Key Design Parameters
- Stroke colors: `#1a1a1a` (dark near-black) for tick marks, numeric/unit/corner text; fill `#f9f9f9` for the case body; `#000000` for the rim arc stroke; `#b3b3b3` for the pivot guide circle.
- Fixed case geometry (identical across every generated scale — only tick/label/text content varies):
- Pivot point (local, pre-translate frame): `(107.42845, 162.62819)`
- Rim / inner-tick radius: `43.842751` mm
- Angular sweep: `±45.667193°` from vertical, clockwise-positive (low value on the left)
- Tick radial lengths: major `4.7839203` mm, medium `3.1215858` mm, small `1.8121802` mm
- Tick stroke widths: major `0.79658329`, medium `0.37985462`, small `0.27478597`
- Label radius: `49.862` mm
- Tick tiers: major ticks at each labeled interval boundary; a medium tick appears only when the minor-tick count per interval is odd, at the exact midpoint subdivision; all other subdivisions render as small ticks.
- Numeric labels rotate to follow the arc (radial orientation), matching their tick's angle.
## Web Generator (`web/`)
A Flask app that generates scale SVGs from a form. The venv lives at the repo root.
```
source .venv/bin/activate
cd web && python app.py # runs on http://localhost:5000
```
`app.py` has one route (`GET /`) serving the form and one (`POST /generate`) that returns SVG. All SVG geometry is computed in `generate_svg()` using the same coordinate constants as the hand-drawn files: ticks and labels are positioned "as if angle=0" (straight up from the pivot) and given an SVG `rotate(angle, PIVOT_X, PIVOT_Y)` transform. The form exposes: unit label, min/max values, scale mapping, major tick intervals, minor ticks per interval, number of numeric labels, custom labels, label size, font, output mode, and left/right corner labels (up to 2 lines each).
Fonts are loaded at startup from every `.ttf` in `fonts/` and embedded as base64 in every generated SVG (text output mode) so output files are self-contained. The default font is `alte-din-1451-mittelschrift.regular`.
## Container & Kubernetes
```
docker build -t e440-scale:latest .
```
`fonts/` must contain the required TTF files before building.
```
kubectl apply -f k8s/
```
`k8s/ingress.yaml` adds a Traefik `IngressRoute` for `works.baumann.gr/E440-generator` and a `stripPrefix` middleware. Adjust `certResolver` in the file if your Traefik instance uses a different name.
## Editing SVG files directly
Open files in **Inkscape**. There is no build, lint, or test tooling — changes are made directly to the SVG XML or via Inkscape's GUI. When modifying tick positions or labels, keep all three files consistent.
```
- [ ] **Step 6: Verify the Docker build still works with the renamed image tag**
```bash
cd /home/adebaumann/development/44-Scale
docker build -t e440-scale:latest . 2>&1 | tail -20
```
Expected: build completes successfully (`Successfully tagged e440-scale:latest` or equivalent final layer message). If Docker isn't available in this environment, skip this step and note it in the final report instead of claiming it was verified.
- [ ] **Step 7: Commit**
```bash
git add web/app.py k8s/ README.md CLAUDE.md
git commit -m "Rebrand infra and docs from M1730 to E440; bump version to 1.0"
```
---
## Final check
- [ ] Run `grep -ril m1730 --include='*.py' --include='*.html' --include='*.yaml' --include='*.md' .` from the repo root and confirm no output (aside from this plan/spec doc's historical references, which are expected to mention M1730 as the prior state).
- [ ] Confirm `git status` shows a clean tree with all changes committed.
- [ ] Confirm the three deliverable SVGs (`E440-Scale_template.svg`, `E440-Scale_paths.svg`, `E440-Scale_text.svg`) exist and the three old `M1730-Scale_*.svg` files are gone.