web: the composer, served behind the existing proxy chain

A browser page with controls on one side and the cross section on the other. Three separately classed layers: printed material, the cavities the stock passes through, and the stock drawn inside its cavity so the fit gap is visible. Under it the report and the design record, so the identity of a part is visible while tuning rather than discovered afterwards. Standard library only, no framework, no build step, no new dependency.

Binding comes from /etc/mechcomp/mechcomp.env, which already declared 10.20.0.10:8770. An earlier draft invented a port on 0.0.0.0, which would have placed a second unproxied plaintext copy beside the proxied one. The fallback with no env file is loopback, never every interface: behind a proxy, binding too narrowly fails loudly as a 502 and too widely fails silently as an open service.

Controls are filtered to the selected profile, with no catch all group. A knob that moves nothing is worse than an absent one.

Renderer tests assert what an eye cannot: the vertical flip happens exactly once, holes render as holes, and the cavity is larger than the stock inside it. Seven mutations on the renderer, all caught.
This commit is contained in:
2026-09-11 06:46:23 -05:00
parent e921cacf0e
commit 14514b0bac
5 changed files with 874 additions and 1 deletions
+3 -1
View File
@@ -1 +1,3 @@
"""Mechanical Compiler - web."""
from .app import build_payload, families, serve
__all__ = ["build_payload", "families", "serve"]
+3
View File
@@ -0,0 +1,3 @@
from .app import serve
serve()
+401
View File
@@ -0,0 +1,401 @@
"""
The composer: a browser page with controls on one side and the part on the other.
Standard library only -- ``http.server``. No framework, no build step, no new
dependency. That is a deliberate constraint for a first usable interface: it
runs anywhere the port already runs, and there is nothing to install or
configure before it will show you a picture.
python -m mechcomp.web # then open the URL it prints
WHAT IT SHOWS
Change a number, see the part. Under it, the report the compiler produced
and the design record that would ship with the model -- so the identity of
what you are looking at is visible while you tune, not discovered
afterwards.
WHAT IT DOES NOT DO
It does not export STL, and it does not persist anything. It is a viewer and
a control panel over ``build()``. Rejections are shown as messages rather
than errors, because a rejection is the compiler doing its job.
"""
from __future__ import annotations
import json
import os
import traceback
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any, Dict, List, Tuple
from urllib.parse import parse_qs, urlparse
from mechcomp import svg
# The deployment's own answers. STAGING-STATE.md section 1 records this file
# as root:mechcomp 0640, and the service user can read it.
ENV_FILE = "/etc/mechcomp/mechcomp.env"
# Parameters that apply to every profile, in the order they make sense to a
# person: what the stock is, how it fits, then how thick the printed walls are.
COMMON_GROUPS: List[Tuple[str, List[str]]] = [
("Stock", ["strap_width_mm", "strap_thickness_mm", "bundle_count"]),
("Fit", ["fit_clearance_mm"]),
("Walls", ["inside_wall_thickness_mm", "outside_wall_thickness_mm",
"edge_wall_thickness_mm", "min_wall_mm"]),
("Model", ["preview_length_mm", "member_length_ft", "material_density_g_cm3",
"facets"]),
]
# Which parameter prefixes belong to which profile, so a person is not shown
# thirty controls of which twenty are irrelevant to what they selected.
PROFILE_PREFIX: Dict[str, Tuple[str, ...]] = {
"Equilateral Triangle": ("ring_corner_", "equilateral_"),
"General Triangle": ("ring_corner_", "general_triangle_"),
"A Frame": ("a_frame_",),
"Y": ("y_",),
"T": ("t_",),
"Three-Fin": ("three_fin_",),
"Square": ("ring_corner_",),
"Rectangle": ("ring_corner_", "rectangle_"),
"Diamond": ("ring_corner_",),
"Cross": ("ring_corner_", "cross_"),
"Four-Fin": ("four_fin_",),
}
def families() -> Dict[str, Any]:
from mechcomp.profiles.four_x import FAMILY as FOUR_X
from mechcomp.profiles.three_x import FAMILY as THREE_X
return {"3x": THREE_X, "4x": FOUR_X}
def profile_params(family, profile: str) -> List[Tuple[str, List[str]]]:
"""Common groups, then the parameters specific to the selected profile."""
defaults = family.defaults
used = {k for _, keys in COMMON_GROUPS for k in keys}
groups: List[Tuple[str, List[str]]] = []
for title, keys in COMMON_GROUPS:
present = [k for k in keys if k in defaults]
if present:
groups.append((title, present))
prefixes = PROFILE_PREFIX.get(profile, ())
specific = [k for k in sorted(defaults)
if k not in used and any(k.startswith(p) for p in prefixes)]
if specific:
groups.append((profile, specific))
# Deliberately no catch-all group. Anything not common and not matching
# this profile's prefixes belongs to a different profile and does
# nothing to the part on screen. A knob that moves nothing is worse than
# an absent one. If a parameter is unreachable, add its prefix to
# PROFILE_PREFIX rather than restoring a fallback.
return groups
def build_payload(family_name: str, profile: str,
overrides: Dict[str, Any]) -> Dict[str, Any]:
from mechcomp.profiles import ProfileRejected, build
family = families()[family_name]
typed: Dict[str, Any] = {}
for key, raw in overrides.items():
if key not in family.defaults:
continue
default = family.defaults[key]
try:
if isinstance(default, bool):
typed[key] = raw not in ("", "0", "false", "False")
elif isinstance(default, int) and not isinstance(default, bool):
typed[key] = int(float(raw))
elif isinstance(default, float):
typed[key] = float(raw)
else:
typed[key] = raw
except (TypeError, ValueError):
continue
payload: Dict[str, Any] = {
"family": family_name,
"profile": profile,
"groups": [[title, keys] for title, keys in profile_params(family, profile)],
"defaults": {k: family.defaults[k] for k in family.defaults},
"values": {**family.defaults, **typed},
"profiles": sorted(family.catalogue),
}
try:
result = build(family=family_name, profile=profile, params=typed)
except ProfileRejected as exc:
payload["ok"] = False
payload["message"] = str(exc)
return payload
except Exception as exc: # noqa: BLE001
payload["ok"] = False
payload["message"] = "%s: %s" % (type(exc).__name__, exc)
payload["trace"] = traceback.format_exc()
return payload
payload["ok"] = True
payload["svg"] = svg.render_result(result)
payload["report"] = {k: result.report[k] for k in sorted(result.report)}
payload["record"] = result.record.render() if result.record else ""
payload["input_id"] = result.record.input_id if result.record else ""
return payload
PAGE = """<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Mechanical Compiler</title>
<style>
:root { --ink:#1c2530; --line:#d3dae1; --bg:#f7f9fb; --accent:#2f6f9f; }
* { box-sizing:border-box; }
body { margin:0; font:14px/1.5 ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
color:var(--ink); background:var(--bg); }
header { padding:14px 20px; background:#fff; border-bottom:1px solid var(--line); }
header h1 { margin:0; font-size:16px; font-weight:640; letter-spacing:.01em; }
header p { margin:3px 0 0; font-size:12px; color:#66727e; }
.wrap { display:grid; grid-template-columns:340px 1fr; gap:0; align-items:start; }
.panel { padding:16px 20px; border-right:1px solid var(--line); background:#fff;
height:calc(100vh - 62px); overflow-y:auto; }
.stage { padding:20px; }
fieldset { border:0; margin:0 0 18px; padding:0; }
legend { font-size:11px; text-transform:uppercase; letter-spacing:.09em;
color:#7b8794; margin-bottom:7px; font-weight:640; }
label { display:grid; grid-template-columns:1fr 92px; gap:8px; align-items:center;
margin-bottom:6px; font-size:12.5px; }
label span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
input,select { font:inherit; font-size:12.5px; padding:4px 7px;
border:1px solid var(--line); border-radius:5px; background:#fff; width:100%; }
input:focus,select:focus { outline:2px solid var(--accent); outline-offset:-1px; }
.figure { background:#fff; border:1px solid var(--line); border-radius:9px;
padding:18px; display:inline-block; min-width:300px; min-height:200px; }
.bad { background:#fff4ed; border:1px solid #f0c3a2; border-radius:9px;
padding:14px 16px; max-width:640px; }
.bad b { display:block; margin-bottom:5px; font-size:12px; text-transform:uppercase;
letter-spacing:.07em; color:#a8642a; }
.cols { display:grid; grid-template-columns:1fr 1fr; gap:18px; margin-top:20px; max-width:900px; }
details { background:#fff; border:1px solid var(--line); border-radius:9px; padding:11px 14px; }
summary { cursor:pointer; font-size:12px; text-transform:uppercase;
letter-spacing:.07em; color:#7b8794; font-weight:640; }
pre { margin:10px 0 0; font:11.5px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace;
white-space:pre-wrap; word-break:break-word; max-height:340px; overflow-y:auto; }
.ident { font:11.5px ui-monospace,Menlo,monospace; color:#66727e; margin-top:9px; }
.toggles { margin-bottom:12px; font-size:12.5px; }
.toggles label { display:inline-flex; grid-template-columns:none; gap:5px; margin-right:14px; }
.toggles input { width:auto; }
</style></head><body>
<header>
<h1>Mechanical Compiler</h1>
<p>Change a number, see the part. Every setting produces a different design id.</p>
</header>
<div class="wrap">
<div class="panel">
<fieldset>
<legend>Member</legend>
<label><span>family</span><select id="family"></select></label>
<label><span>profile</span><select id="profile"></select></label>
</fieldset>
<div id="controls"></div>
</div>
<div class="stage">
<div class="toggles">
<label><input type="checkbox" id="t-material" checked> material</label>
<label><input type="checkbox" id="t-cavity" checked> cavities</label>
<label><input type="checkbox" id="t-stock" checked> stock</label>
</div>
<div id="out"></div>
<div class="cols">
<details open><summary>Report</summary><pre id="report"></pre></details>
<details><summary>Design record</summary><pre id="record"></pre></details>
</div>
</div>
</div>
<script>
let state = { family:"3x", profile:"Y", values:{} };
async function refresh(sendValues) {
const q = new URLSearchParams();
q.set("family", state.family);
q.set("profile", state.profile);
if (sendValues) for (const [k,v] of Object.entries(state.values)) q.set(k, v);
const data = await (await fetch("/api/build?" + q.toString())).json();
const fam = document.getElementById("family");
if (!fam.options.length) {
for (const f of ["3x","4x"]) fam.add(new Option(f, f));
fam.value = state.family;
fam.onchange = () => { state.family = fam.value; state.values = {}; refresh(false); };
}
const prof = document.getElementById("profile");
prof.innerHTML = "";
for (const p of data.profiles) prof.add(new Option(p, p));
prof.value = data.profile;
prof.onchange = () => { state.profile = prof.value; refresh(true); };
state.values = {};
const box = document.getElementById("controls");
box.innerHTML = "";
for (const [title, keys] of data.groups) {
const fs = document.createElement("fieldset");
fs.innerHTML = "<legend>" + title + "</legend>";
for (const k of keys) {
const v = data.values[k];
state.values[k] = v;
const lab = document.createElement("label");
const name = document.createElement("span");
name.textContent = k.replace(/_mm$|_deg$|_ft$/, "").replace(/_/g, " ");
name.title = k;
const inp = document.createElement("input");
inp.value = v;
if (typeof v === "number") { inp.type = "number"; inp.step = "any"; }
inp.onchange = () => { state.values[k] = inp.value; refresh(true); };
lab.append(name, inp);
fs.append(lab);
}
box.append(fs);
}
const out = document.getElementById("out");
if (data.ok) {
out.innerHTML = '<div class="figure">' + data.svg + '</div>'
+ '<div class="ident">design ' + data.input_id + '</div>';
document.getElementById("report").textContent =
Object.entries(data.report).map(([k,v]) => k + " = " + v).join("\\n");
document.getElementById("record").textContent = data.record;
applyToggles();
} else {
out.innerHTML = '<div class="bad"><b>Not buildable</b>' + escapeHtml(data.message) + '</div>';
}
}
function escapeHtml(s) {
return String(s).replace(/[&<>]/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;"}[c]));
}
function applyToggles() {
for (const [id, cls] of [["t-material","mc-layer-material"],
["t-cavity","mc-layer-cavity"],
["t-stock","mc-layer-stock"]]) {
const on = document.getElementById(id).checked;
document.querySelectorAll("." + cls).forEach(g => g.style.display = on ? "" : "none");
}
}
for (const id of ["t-material","t-cavity","t-stock"])
document.getElementById(id).onchange = applyToggles;
refresh(false);
</script></body></html>
"""
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
pass
def _send(self, code: int, body: bytes, ctype: str):
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
parsed = urlparse(self.path)
if parsed.path in ("/", "/index.html"):
self._send(200, PAGE.encode("utf-8"), "text/html; charset=utf-8")
return
if parsed.path == "/api/build":
q = {k: v[0] for k, v in parse_qs(parsed.query).items()}
family = q.pop("family", "3x")
profile = q.pop("profile", "Y")
try:
payload = build_payload(family, profile, q)
except Exception as exc: # noqa: BLE001
payload = {"ok": False,
"message": "%s: %s" % (type(exc).__name__, exc),
"profiles": [], "groups": [], "values": {}}
self._send(200, json.dumps(payload).encode("utf-8"),
"application/json; charset=utf-8")
return
self._send(404, b"not found", "text/plain; charset=utf-8")
def read_env_file(path: str = ENV_FILE) -> Dict[str, str]:
"""
Parse the deployment's environment file.
Plain ``KEY=value`` lines, ``#`` comments, no shell. Absent or unreadable
is not an error -- the composer must still run from a checkout on a
machine that has no deployment at all.
"""
out: Dict[str, str] = {}
try:
with open(path, encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
out[key.strip()] = value.split("#")[0].strip()
except OSError:
pass
return out
def resolve_binding(env_file: str = ENV_FILE) -> Tuple[str, int]:
"""
Where to listen: the real environment first, then the deployment file.
THE FALLBACK IS LOOPBACK, NOT 0.0.0.0, AND THAT IS THE POINT.
This service sits behind nginx on CT 101, which terminates TLS and
proxies to the service address. Binding every interface would place a
second unproxied plaintext copy beside it. Behind a proxy, binding
too narrowly fails loudly as a 502; binding too widely fails silently
as an open service nobody notices. Prefer the loud failure.
"""
env = read_env_file(env_file)
host = (os.environ.get("MECHCOMP_BIND")
or env.get("MECHCOMP_BIND")
or "127.0.0.1")
raw_port = (os.environ.get("MECHCOMP_PORT")
or env.get("MECHCOMP_PORT")
or "8770")
try:
port = int(raw_port)
except (TypeError, ValueError):
port = 8770
return host, port
def base_url(env_file: str = ENV_FILE) -> str:
"""The URL a person actually types. Empty when nothing declares one."""
env = read_env_file(env_file)
return os.environ.get("MECHCOMP_BASE_URL") or env.get("MECHCOMP_BASE_URL", "")
def serve(host: str = "", port: int = 0) -> None:
default_host, default_port = resolve_binding()
host = host or default_host
port = port or default_port
server = ThreadingHTTPServer((host, port), Handler)
print("Mechanical Compiler composer listening on %s:%d" % (host, port))
public = base_url()
if public:
print("Reached through the proxy at %s" % public)
print("Ctrl-C to stop.")
server.serve_forever()
if __name__ == "__main__":
import argparse
ap = argparse.ArgumentParser(description="Serve the composer.")
ap.add_argument("--host", default="",
help="override MECHCOMP_BIND (default: the env file)")
ap.add_argument("--port", type=int, default=0,
help="override MECHCOMP_PORT (default: the env file)")
serve(**vars(ap.parse_args()))