diff --git a/src/mechcomp/svg.py b/src/mechcomp/svg.py new file mode 100644 index 0000000..7fed19c --- /dev/null +++ b/src/mechcomp/svg.py @@ -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] = [ + '' + % (_fmt(x0), _fmt(y0), _fmt(w), _fmt(h), width_px, height_px), + '', + # One flip for the whole drawing. Everything below is in geometry + # coordinates, so a path here can be read against the report directly. + '' % _fmt(y0 + y1), + ] + + out.append('' + '' + % path_d(section)) + + if cavities: + out.append('' + '' + % path_d(cavities)) + + if stock: + out.append('' + '' + % path_d(list(stock))) + + out.append('') + out.append('') + 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) diff --git a/src/mechcomp/web/__init__.py b/src/mechcomp/web/__init__.py index 65cb58a..7d76880 100644 --- a/src/mechcomp/web/__init__.py +++ b/src/mechcomp/web/__init__.py @@ -1 +1,3 @@ -"""Mechanical Compiler - web.""" +from .app import build_payload, families, serve + +__all__ = ["build_payload", "families", "serve"] diff --git a/src/mechcomp/web/__main__.py b/src/mechcomp/web/__main__.py new file mode 100644 index 0000000..07c1b4c --- /dev/null +++ b/src/mechcomp/web/__main__.py @@ -0,0 +1,3 @@ +from .app import serve + +serve() diff --git a/src/mechcomp/web/app.py b/src/mechcomp/web/app.py new file mode 100644 index 0000000..5046864 --- /dev/null +++ b/src/mechcomp/web/app.py @@ -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 = """ + + +Mechanical Compiler + +
+

Mechanical Compiler

+

Change a number, see the part. Every setting produces a different design id.

+
+
+
+
+ Member + + +
+
+
+
+
+ + + +
+
+
+
Report
+
Design record
+
+
+
+ +""" + + +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())) diff --git a/tests/test_svg_and_web.py b/tests/test_svg_and_web.py new file mode 100644 index 0000000..f4c8400 --- /dev/null +++ b/tests/test_svg_and_web.py @@ -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("") + 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("