1202 lines
49 KiB
Markdown
1202 lines
49 KiB
Markdown
# 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 3–4.
|
||
|
||
- [ ] **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 3–6; 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 (0–50, 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 1–7.
|
||
|
||
- [ ] **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 0–50 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 1–6, run with the reference parameters (unit `uA`, 0–50, 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.
|