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] = [ + '') + 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 = """ +
+ +Change a number, see the part. Every setting produces a different design id.
+