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:
@@ -0,0 +1,149 @@
|
|||||||
|
"""
|
||||||
|
Render a cross-section as SVG.
|
||||||
|
|
||||||
|
WHY SEPARATELY CLASSED PARTS
|
||||||
|
ROADMAP section 4 gives this as the reason the port exists at all: the
|
||||||
|
outline, each stock cavity and the stock itself are emitted as distinct
|
||||||
|
classed groups, so a viewer can highlight or hide them independently. A
|
||||||
|
single flattened silhouette would have been far easier and would not have
|
||||||
|
justified leaving OpenSCAD.
|
||||||
|
|
||||||
|
COORDINATES
|
||||||
|
Geometry has +Y up; SVG has +Y down. The whole drawing is flipped once in a
|
||||||
|
wrapping transform rather than by negating coordinates, so the paths in the
|
||||||
|
file remain the geometry's own numbers and can be read against the report.
|
||||||
|
|
||||||
|
HOLES
|
||||||
|
A region is a list of rings, some of which are holes, and their winding
|
||||||
|
direction is not something this module wants to depend on. Every ring of a
|
||||||
|
region goes into one path with ``fill-rule="evenodd"``, which renders holes
|
||||||
|
correctly whichever way they wind.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Iterable, List, Optional, Sequence, Tuple
|
||||||
|
|
||||||
|
Point = Tuple[float, float]
|
||||||
|
Path = List[Point]
|
||||||
|
Region = Sequence[Path]
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt(v: float) -> str:
|
||||||
|
"""Trim to 4 decimal places -- well below anything visible at any zoom."""
|
||||||
|
return ("%.4f" % v).rstrip("0").rstrip(".") or "0"
|
||||||
|
|
||||||
|
|
||||||
|
def path_d(region: Region) -> str:
|
||||||
|
"""One SVG path definition covering every ring of a region."""
|
||||||
|
parts: List[str] = []
|
||||||
|
for ring in region:
|
||||||
|
if len(ring) < 3:
|
||||||
|
continue
|
||||||
|
parts.append("M " + " L ".join("%s %s" % (_fmt(x), _fmt(y))
|
||||||
|
for x, y in ring) + " Z")
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def bounds(regions: Iterable[Region]) -> Tuple[float, float, float, float]:
|
||||||
|
xs: List[float] = []
|
||||||
|
ys: List[float] = []
|
||||||
|
for region in regions:
|
||||||
|
for ring in region:
|
||||||
|
for x, y in ring:
|
||||||
|
xs.append(x)
|
||||||
|
ys.append(y)
|
||||||
|
if not xs:
|
||||||
|
return (-1.0, -1.0, 1.0, 1.0)
|
||||||
|
return (min(xs), min(ys), max(xs), max(ys))
|
||||||
|
|
||||||
|
|
||||||
|
def render(section: Region,
|
||||||
|
cavities: Optional[Region] = None,
|
||||||
|
stock: Optional[Sequence[Path]] = None,
|
||||||
|
margin: float = 3.0,
|
||||||
|
width_px: int = 520) -> str:
|
||||||
|
"""
|
||||||
|
The cross-section as a standalone SVG document.
|
||||||
|
|
||||||
|
``section`` is the printed material. ``cavities`` are the voids the stock
|
||||||
|
passes through. ``stock`` is the stock itself, drawn inside its cavity so
|
||||||
|
the fit gap is visible -- which is the whole point of looking at it.
|
||||||
|
"""
|
||||||
|
regions: List[Region] = [section]
|
||||||
|
if cavities:
|
||||||
|
regions.append(cavities)
|
||||||
|
if stock:
|
||||||
|
regions.append(list(stock))
|
||||||
|
|
||||||
|
x0, y0, x1, y1 = bounds(regions)
|
||||||
|
x0 -= margin
|
||||||
|
y0 -= margin
|
||||||
|
x1 += margin
|
||||||
|
y1 += margin
|
||||||
|
w = x1 - x0
|
||||||
|
h = y1 - y0
|
||||||
|
height_px = int(round(width_px * (h / w))) if w > 0 else width_px
|
||||||
|
|
||||||
|
out: List[str] = [
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" '
|
||||||
|
'viewBox="%s %s %s %s" width="%d" height="%d" '
|
||||||
|
'class="mechcomp-section">'
|
||||||
|
% (_fmt(x0), _fmt(y0), _fmt(w), _fmt(h), width_px, height_px),
|
||||||
|
'<style>',
|
||||||
|
'.mc-material { fill: #b9c6d4; stroke: #33414f; stroke-width: 0.25; }',
|
||||||
|
'.mc-cavity { fill: #f4b183; stroke: #a8642a; stroke-width: 0.15; }',
|
||||||
|
'.mc-stock { fill: #55606c; stroke: none; }',
|
||||||
|
'</style>',
|
||||||
|
# One flip for the whole drawing. Everything below is in geometry
|
||||||
|
# coordinates, so a path here can be read against the report directly.
|
||||||
|
'<g transform="translate(0,%s) scale(1,-1)">' % _fmt(y0 + y1),
|
||||||
|
]
|
||||||
|
|
||||||
|
out.append('<g class="mc-layer-material">'
|
||||||
|
'<path class="mc-material" fill-rule="evenodd" d="%s"/></g>'
|
||||||
|
% path_d(section))
|
||||||
|
|
||||||
|
if cavities:
|
||||||
|
out.append('<g class="mc-layer-cavity">'
|
||||||
|
'<path class="mc-cavity" fill-rule="evenodd" d="%s"/></g>'
|
||||||
|
% path_d(cavities))
|
||||||
|
|
||||||
|
if stock:
|
||||||
|
out.append('<g class="mc-layer-stock">'
|
||||||
|
'<path class="mc-stock" fill-rule="evenodd" d="%s"/></g>'
|
||||||
|
% path_d(list(stock)))
|
||||||
|
|
||||||
|
out.append('</g>')
|
||||||
|
out.append('</svg>')
|
||||||
|
return "\n".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def render_result(result, geo=None) -> str:
|
||||||
|
"""
|
||||||
|
Render whatever ``build()`` returned.
|
||||||
|
|
||||||
|
Cavities and stock are drawn when the geometry layer can supply them, and
|
||||||
|
quietly omitted when it cannot -- a picture of the material alone is still
|
||||||
|
useful, and a viewer should not fail because a helper moved.
|
||||||
|
"""
|
||||||
|
section = result.section
|
||||||
|
cavities = None
|
||||||
|
stock = None
|
||||||
|
|
||||||
|
g = geo if geo is not None else getattr(result, "geo", None)
|
||||||
|
members = getattr(result, "members", None)
|
||||||
|
|
||||||
|
if g is not None and members:
|
||||||
|
try:
|
||||||
|
from .geom.join import cavity_region
|
||||||
|
cavities = cavity_region(members, g)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
cavities = None
|
||||||
|
try:
|
||||||
|
from .geom.records import strap_path
|
||||||
|
stock = [strap_path(m, g) for m in members]
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
stock = None
|
||||||
|
|
||||||
|
return render(section, cavities, stock)
|
||||||
@@ -1 +1,3 @@
|
|||||||
"""Mechanical Compiler - web."""
|
from .app import build_payload, families, serve
|
||||||
|
|
||||||
|
__all__ = ["build_payload", "families", "serve"]
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .app import serve
|
||||||
|
|
||||||
|
serve()
|
||||||
@@ -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 => ({"&":"&","<":"<",">":">"}[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()))
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
"""
|
||||||
|
The renderer and the composer, tested the way a viewer can actually fail.
|
||||||
|
|
||||||
|
A picture that looks plausible while being wrong is worse than no picture, and
|
||||||
|
this is the first part of the system CIVICVS will judge by eye rather than by
|
||||||
|
test output. So the things asserted here are the ones an eye cannot check: that
|
||||||
|
the flip is applied exactly once, that holes are not filled in, that a cavity
|
||||||
|
really is larger than the stock inside it, and that a rejection reaches the
|
||||||
|
screen as a message rather than a stack trace.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mechcomp import svg
|
||||||
|
|
||||||
|
SQUARE = [[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]]
|
||||||
|
WITH_HOLE = [
|
||||||
|
[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)],
|
||||||
|
[(4.0, 4.0), (6.0, 4.0), (6.0, 6.0), (4.0, 6.0)],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The renderer
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_every_ring_reaches_the_path():
|
||||||
|
"""A hole silently dropped would render as solid material."""
|
||||||
|
d = svg.path_d(WITH_HOLE)
|
||||||
|
assert d.count("M ") == 2, d
|
||||||
|
assert d.count("Z") == 2
|
||||||
|
assert "4 4" in d.replace(".0", "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_holes_use_evenodd_so_winding_does_not_matter():
|
||||||
|
"""
|
||||||
|
Ring winding is not something this module should have to know. evenodd
|
||||||
|
renders a hole as a hole either way, and its absence is the difference
|
||||||
|
between a bore and a solid plug.
|
||||||
|
"""
|
||||||
|
out = svg.render(WITH_HOLE)
|
||||||
|
assert out.count('fill-rule="evenodd"') >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_degenerate_rings_are_skipped_not_emitted():
|
||||||
|
assert svg.path_d([[(0.0, 0.0), (1.0, 1.0)]]) == ""
|
||||||
|
assert svg.path_d([[]]) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_vertical_flip_is_applied_exactly_once():
|
||||||
|
"""
|
||||||
|
Geometry has +Y up, SVG has +Y down. Flipping twice, or not at all, gives a
|
||||||
|
part that is upside down -- which for an asymmetric profile like T or A
|
||||||
|
Frame is wrong in a way that is easy to miss and impossible to print
|
||||||
|
around.
|
||||||
|
"""
|
||||||
|
out = svg.render(SQUARE)
|
||||||
|
assert out.count("scale(1,-1)") == 1
|
||||||
|
assert len(re.findall(r"transform=", out)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_coordinates_are_left_in_geometry_space():
|
||||||
|
"""
|
||||||
|
The flip is in the transform, so the numbers in the file are the geometry's
|
||||||
|
own and can be read against the report. Negating them instead would make
|
||||||
|
the file unreadable against anything.
|
||||||
|
"""
|
||||||
|
out = svg.render(SQUARE)
|
||||||
|
assert " 10 " in out or " 10 10" in out
|
||||||
|
assert "-10" not in svg.path_d(SQUARE)
|
||||||
|
|
||||||
|
|
||||||
|
def test_viewbox_contains_the_geometry_plus_margin():
|
||||||
|
out = svg.render(SQUARE, margin=3.0)
|
||||||
|
vb = re.search(r'viewBox="([-\d. ]+)"', out).group(1).split()
|
||||||
|
x0, y0, w, h = (float(v) for v in vb)
|
||||||
|
assert x0 == pytest.approx(-3.0)
|
||||||
|
assert y0 == pytest.approx(-3.0)
|
||||||
|
assert w == pytest.approx(16.0)
|
||||||
|
assert h == pytest.approx(16.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_layers_are_separately_classed():
|
||||||
|
"""
|
||||||
|
ROADMAP section 4's stated reason for the port. A flattened silhouette
|
||||||
|
would not have justified leaving OpenSCAD.
|
||||||
|
"""
|
||||||
|
out = svg.render(SQUARE, cavities=WITH_HOLE, stock=[SQUARE[0]])
|
||||||
|
for cls in ("mc-layer-material", "mc-layer-cavity", "mc-layer-stock"):
|
||||||
|
assert cls in out, cls
|
||||||
|
|
||||||
|
|
||||||
|
def test_optional_layers_are_omitted_when_absent():
|
||||||
|
out = svg.render(SQUARE)
|
||||||
|
assert "mc-layer-material" in out
|
||||||
|
assert "mc-layer-cavity" not in out
|
||||||
|
assert "mc-layer-stock" not in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_bounds_of_nothing_does_not_divide_by_zero():
|
||||||
|
assert svg.bounds([]) == (-1.0, -1.0, 1.0, 1.0)
|
||||||
|
assert svg.render([]).startswith("<svg")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Against real geometry
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_a_real_profile_renders_with_all_three_layers():
|
||||||
|
build = pytest.importorskip("mechcomp.profiles").build
|
||||||
|
|
||||||
|
result = build(family="3x", profile="Y", params={})
|
||||||
|
out = svg.render_result(result)
|
||||||
|
|
||||||
|
assert out.startswith("<svg") and out.rstrip().endswith("</svg>")
|
||||||
|
for cls in ("mc-layer-material", "mc-layer-cavity", "mc-layer-stock"):
|
||||||
|
assert cls in out, "%s missing from a real render" % cls
|
||||||
|
assert out.count("scale(1,-1)") == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_cavity_is_drawn_larger_than_the_stock_in_it():
|
||||||
|
"""
|
||||||
|
The fit gap is the reason to look at the picture at all. If cavity and stock
|
||||||
|
render identically, the clearance is invisible and the viewer is useless for
|
||||||
|
the one judgement it exists to support.
|
||||||
|
"""
|
||||||
|
build = pytest.importorskip("mechcomp.profiles").build
|
||||||
|
from mechcomp.geom.join import cavity_region
|
||||||
|
from mechcomp.geom.records import strap_path
|
||||||
|
from mechcomp.geom.region import area as region_area
|
||||||
|
|
||||||
|
result = build(family="3x", profile="Y", params={"fit_clearance_mm": 0.4})
|
||||||
|
cav = cavity_region(result.members, result.geo)
|
||||||
|
stock = [strap_path(m, result.geo) for m in result.members]
|
||||||
|
|
||||||
|
assert region_area(cav) > region_area(stock), (
|
||||||
|
"cavity is not larger than the stock it holds")
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_profile_in_both_families_renders():
|
||||||
|
"""A viewer that works for Y and breaks on Cross is not a catalogue."""
|
||||||
|
profiles = pytest.importorskip("mechcomp.profiles")
|
||||||
|
from mechcomp.web.app import families
|
||||||
|
|
||||||
|
for name, family in families().items():
|
||||||
|
for profile in sorted(family.catalogue):
|
||||||
|
try:
|
||||||
|
result = profiles.build(family=name, profile=profile, params={})
|
||||||
|
except profiles.ProfileRejected:
|
||||||
|
continue
|
||||||
|
out = svg.render_result(result)
|
||||||
|
assert out.startswith("<svg"), "%s/%s" % (name, profile)
|
||||||
|
assert "mc-layer-material" in out, "%s/%s" % (name, profile)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The composer payload
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_binding_comes_from_the_deployment_not_from_a_guess(tmp_path,
|
||||||
|
monkeypatch):
|
||||||
|
"""
|
||||||
|
The deployment already declares where this service lives. Inventing a
|
||||||
|
port put it somewhere nginx does not look and, worse, on every interface
|
||||||
|
the container has.
|
||||||
|
"""
|
||||||
|
from mechcomp.web.app import base_url, resolve_binding
|
||||||
|
|
||||||
|
for key in ("MECHCOMP_BIND", "MECHCOMP_PORT", "MECHCOMP_BASE_URL"):
|
||||||
|
monkeypatch.delenv(key, raising=False)
|
||||||
|
|
||||||
|
envf = tmp_path / "mechcomp.env"
|
||||||
|
envf.write_text(
|
||||||
|
"# staging\n"
|
||||||
|
"MECHCOMP_ENV=staging\n"
|
||||||
|
"MECHCOMP_BIND=10.20.0.10 # scalar; loopback is NOT bound\n"
|
||||||
|
"MECHCOMP_PORT=8770\n"
|
||||||
|
"MECHCOMP_BASE_URL=https://mechanical-compiler.dev.infra\n")
|
||||||
|
|
||||||
|
assert resolve_binding(str(envf)) == ("10.20.0.10", 8770)
|
||||||
|
assert base_url(str(envf)) == "https://mechanical-compiler.dev.infra"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_real_environment_variable_wins_over_the_file(tmp_path, monkeypatch):
|
||||||
|
from mechcomp.web.app import resolve_binding
|
||||||
|
|
||||||
|
envf = tmp_path / "mechcomp.env"
|
||||||
|
envf.write_text("MECHCOMP_BIND=10.20.0.10\nMECHCOMP_PORT=8770\n")
|
||||||
|
monkeypatch.setenv("MECHCOMP_PORT", "9001")
|
||||||
|
monkeypatch.delenv("MECHCOMP_BIND", raising=False)
|
||||||
|
|
||||||
|
assert resolve_binding(str(envf)) == ("10.20.0.10", 9001)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_fallback_is_loopback_never_every_interface(tmp_path, monkeypatch):
|
||||||
|
"""
|
||||||
|
Behind a reverse proxy, binding too narrowly fails loudly as a 502 and
|
||||||
|
binding too widely fails silently as an open service. If this ever
|
||||||
|
returns 0.0.0.0 by default, the quiet failure is back.
|
||||||
|
"""
|
||||||
|
from mechcomp.web.app import resolve_binding
|
||||||
|
|
||||||
|
for key in ("MECHCOMP_BIND", "MECHCOMP_PORT"):
|
||||||
|
monkeypatch.delenv(key, raising=False)
|
||||||
|
|
||||||
|
host, port = resolve_binding(str(tmp_path / "absent.env"))
|
||||||
|
assert host == "127.0.0.1", host
|
||||||
|
assert host not in ("0.0.0.0", "::", ""), "bound every interface"
|
||||||
|
assert port == 8770
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_malformed_env_file_does_not_widen_the_binding(tmp_path, monkeypatch):
|
||||||
|
from mechcomp.web.app import resolve_binding
|
||||||
|
|
||||||
|
for key in ("MECHCOMP_BIND", "MECHCOMP_PORT"):
|
||||||
|
monkeypatch.delenv(key, raising=False)
|
||||||
|
|
||||||
|
envf = tmp_path / "mechcomp.env"
|
||||||
|
envf.write_text("garbage\nMECHCOMP_PORT=not-a-number\n[section]\n")
|
||||||
|
|
||||||
|
host, port = resolve_binding(str(envf))
|
||||||
|
assert host == "127.0.0.1"
|
||||||
|
assert port == 8770
|
||||||
|
|
||||||
|
|
||||||
|
def test_payload_carries_a_picture_a_report_and_an_identity():
|
||||||
|
pytest.importorskip("mechcomp.profiles")
|
||||||
|
from mechcomp.web.app import build_payload
|
||||||
|
|
||||||
|
p = build_payload("3x", "Y", {})
|
||||||
|
assert p["ok"] is True
|
||||||
|
assert p["svg"].startswith("<svg")
|
||||||
|
assert p["report"]["PROFILE"] == "Y"
|
||||||
|
assert p["input_id"], "no design id on the payload"
|
||||||
|
assert "MECHCOMP DESIGN RECORD" in p["record"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_changing_a_control_changes_the_part_and_its_identity():
|
||||||
|
"""The composer's whole premise, end to end."""
|
||||||
|
pytest.importorskip("mechcomp.profiles")
|
||||||
|
from mechcomp.web.app import build_payload
|
||||||
|
|
||||||
|
a = build_payload("3x", "Y", {"fit_clearance_mm": "0.15"})
|
||||||
|
b = build_payload("3x", "Y", {"fit_clearance_mm": "0.35"})
|
||||||
|
assert a["ok"] and b["ok"]
|
||||||
|
assert a["svg"] != b["svg"], "the drawing did not change"
|
||||||
|
assert a["input_id"] != b["input_id"], "two settings share one design id"
|
||||||
|
|
||||||
|
|
||||||
|
def test_form_strings_are_coerced_to_the_types_the_compiler_expects():
|
||||||
|
"""Everything arrives from a browser as text; ints must not become floats."""
|
||||||
|
pytest.importorskip("mechcomp.profiles")
|
||||||
|
from mechcomp.web.app import build_payload
|
||||||
|
|
||||||
|
p = build_payload("3x", "Y", {"bundle_count": "2", "fit_clearance_mm": "0.3"})
|
||||||
|
assert p["ok"] is True
|
||||||
|
assert p["values"]["bundle_count"] == 2
|
||||||
|
assert isinstance(p["values"]["bundle_count"], int)
|
||||||
|
assert p["values"]["fit_clearance_mm"] == 0.3
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_rejection_is_a_message_not_a_crash():
|
||||||
|
"""
|
||||||
|
A rejection is the compiler working. It must read as an explanation on the
|
||||||
|
screen, and it must still name the parameter and the limit.
|
||||||
|
"""
|
||||||
|
pytest.importorskip("mechcomp.profiles")
|
||||||
|
from mechcomp.web.app import build_payload
|
||||||
|
|
||||||
|
p = build_payload("3x", "Y", {"min_wall_mm": "0"})
|
||||||
|
assert p["ok"] is False
|
||||||
|
assert p["message"].strip()
|
||||||
|
assert "trace" not in p
|
||||||
|
assert "min_wall_mm" in p["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_nonsense_input_does_not_take_the_page_down():
|
||||||
|
pytest.importorskip("mechcomp.profiles")
|
||||||
|
from mechcomp.web.app import build_payload
|
||||||
|
|
||||||
|
p = build_payload("3x", "Y", {"fit_clearance_mm": "banana",
|
||||||
|
"not_a_parameter": "7"})
|
||||||
|
assert "ok" in p
|
||||||
|
assert "not_a_parameter" not in p["values"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_controls_offered_are_relevant_to_the_selected_profile():
|
||||||
|
"""
|
||||||
|
Thirty controls of which twenty do nothing is not a composer. Selecting Y
|
||||||
|
must not present the Three-Fin parameters.
|
||||||
|
"""
|
||||||
|
pytest.importorskip("mechcomp.profiles")
|
||||||
|
from mechcomp.web.app import build_payload
|
||||||
|
|
||||||
|
y = build_payload("3x", "Y", {})
|
||||||
|
offered = {k for _, keys in y["groups"] for k in keys}
|
||||||
|
assert any(k.startswith("y_") for k in offered)
|
||||||
|
assert not any(k.startswith("three_fin_") for k in offered), sorted(offered)
|
||||||
|
assert "strap_width_mm" in offered and "fit_clearance_mm" in offered
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_offered_control_is_a_real_parameter():
|
||||||
|
"""A control that does nothing is worse than a missing one."""
|
||||||
|
pytest.importorskip("mechcomp.profiles")
|
||||||
|
from mechcomp.web.app import build_payload, families
|
||||||
|
|
||||||
|
for name, family in families().items():
|
||||||
|
for profile in sorted(family.catalogue):
|
||||||
|
p = build_payload(name, profile, {})
|
||||||
|
for _, keys in p["groups"]:
|
||||||
|
for key in keys:
|
||||||
|
assert key in family.defaults, (
|
||||||
|
"%s/%s offers %s, which is not a parameter"
|
||||||
|
% (name, profile, key))
|
||||||
Reference in New Issue
Block a user