Files
maziggy 08df660f6c Replace the embedded G-code viewer with the slicer's own renderer
Sliced files previewed through a vendored copy of PrettyGCode in an
iframe. It drew each move as a screen-space line -- a line has no
thickness in the scene, so it cannot occlude the layer behind it, which
is why prints came out stringy and shimmered where layers crossed. Being
a separate app in a frame, it could be neither themed nor translated, and
carried its own machinery for detecting a proxy refusing the embed.

Now built on libvgcode, the renderer OrcaSlicer draws its own preview
with, vendored from three-slicer (AGPL, same as us). It takes the THREE
namespace as an argument and imports nothing, so it runs on our 0.181
rather than the 0.160 its package pins.

The parser is ours; upstream renders its own kernel's output and ships no
G-code parser at all. Two things it has to get right, both found by
checking a real plate rather than assuming:

- BambuStudio does not use the OrcaSlicer/PrusaSlicer annotations. It
  writes "; FEATURE:", "; LINE_WIDTH:", "; CHANGE_LAYER" and
  "; Z_HEIGHT:", not ";TYPE:", ";WIDTH:" and ";LAYER_CHANGE". Reading
  only the latter showed a 52-layer print as 23,165 layers in one colour,
  because with no layer marker recognised every travel Z-hop split a
  layer and every segment took the fallback feature.
- It emits a tenth of its moves as G2/G3 arcs -- 706 extruding ones in a
  single plate. Ignoring them punched holes through curved walls and tree
  supports. Arcs with no X/Y are the helical travel lift and lay down
  nothing, so they interpolate as travels.

Four colour modes: filament (default, from the AMS slots the file was
sliced with), feature, layer height, line width. Speed, fan and
temperature are deliberately absent -- upstream derives those from
settings rather than the toolpath, and guesses dressed as measurements
are worse than an honest omission. The parser now carries the data to do
them properly later.

Legend entries are switches. Hiding removes the records before the mesh
is built rather than recolouring them: the shader packs colour into a
single float with no alpha, so there is no transparent to set, and
removal is the useful behaviour anyway -- a hidden support stops
occluding what it covered.

The scene is built once and only the toolpath rebuilds. Doing otherwise
constructed a new WebGLRenderer on every render, because the buildVolume
default is an object literal and so a fresh identity each time; browsers
cap live WebGL contexts and drop the oldest, which blanked the canvas
after a few interactions.

utils/framing.ts goes with the iframe, along with six now-orphaned
strings in all 13 locales. src/lib/vendor is excluded from eslint --
acting on findings in vendored code makes it impossible to re-copy on the
next upstream release.
2026-08-09 14:10:18 +02:00

433 lines
16 KiB
Python

"""Build script for the Bambuddy Windows installer.
Stages all artifacts under ``installers/windows/build/staging/`` for the
Inno Setup compiler to package. Run this on Windows (or in a Windows CI
runner) — it pip-installs Bambuddy's deps against the embedded Python it
downloads, which requires the matching platform.
Steps:
1. Download python.org embeddable distribution for Windows x64
2. Configure embedded Python (allow site-packages)
3. Bootstrap pip into the embedded distribution
4. Install ``requirements.txt`` into the embedded Python
5. Build the React frontend (``frontend/npm run build``)
6. Stage backend source + frontend bundle
7. Download NSSM
8. Download ffmpeg static build for Windows
9. Print "ready for ISCC" message
After this script succeeds, run::
"C:\\Program Files (x86)\\Inno Setup 6\\ISCC.exe" bambuddy.iss
to produce the final installer .exe under ``build/output/``.
"""
from __future__ import annotations
import argparse
import os
import shutil
import subprocess
import sys
import urllib.request
import zipfile
from pathlib import Path
# Repo root: installers/windows/build.py -> ../../
REPO_ROOT = Path(__file__).resolve().parents[2]
INSTALLER_DIR = Path(__file__).resolve().parent
BUILD_DIR = INSTALLER_DIR / "build"
STAGING = BUILD_DIR / "staging"
DOWNLOADS = BUILD_DIR / "downloads"
# Python 3.13 — matches Dockerfile (python:3.13-slim-trixie). Bump when
# the Dockerfile bumps; the Windows installer should track production.
PYTHON_VERSION = "3.13.1"
PYTHON_EMBED_URL = f"https://www.python.org/ftp/python/{PYTHON_VERSION}/python-{PYTHON_VERSION}-embed-amd64.zip"
# NSSM 2.24 is the long-time stable build (no new release since 2014).
# Vendored under installers/windows/vendor/nssm.exe rather than fetched
# at build time — nssm.cc has flaked with 503s mid-CI-run before, and
# pinning to a checked-in binary makes builds reproducible and lets us
# inspect the binary in PRs if it ever needs updating. SHA-256:
# f689ee9af94b00e9e3f0bb072b34caaf207f32dcb4f5782fc9ca351df9a06c97
NSSM_VERSION = "2.24"
# ffmpeg static build. BtbN's gyan-equivalent build is the most reliable
# automated source. Pin to a release tag so builds are reproducible.
FFMPEG_URL = "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip"
# get-pip.py for bootstrapping pip into the embedded distribution
GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py"
# C++ runtime DLLs the embeddable distribution does NOT ship. The python.org
# embeddable zip includes vcruntime140.dll but not vcruntime140_1.dll or
# msvcp140.dll. python313.dll is pure C and only needs vcruntime140.dll, so
# python.exe starts fine — but greenlet's _greenlet.pyd is C++ and needs
# vcruntime140_1.dll (table-based exception handling). On a fresh Windows box
# that never had the VC++ 2015-2022 redistributable installed, loading greenlet
# fails with "DLL load failed ... The specified module could not be found",
# SQLAlchemy's async engine can't start, init_db() raises, and the app never
# binds its port — the service shows "running" but the dashboard refuses the
# connection (issue #2474). These runtime DLLs are redistributable, so we ship
# them app-locally next to python.exe (where vcruntime140.dll already lives).
VCRUNTIME_DLLS = ("vcruntime140_1.dll", "msvcp140.dll")
def log(msg: str) -> None:
print(f"[build] {msg}", flush=True)
def download(url: str, dest: Path) -> Path:
"""Download ``url`` to ``dest`` if not already present."""
if dest.exists():
log(f"already downloaded: {dest.name}")
return dest
dest.parent.mkdir(parents=True, exist_ok=True)
log(f"downloading {url}")
with urllib.request.urlopen(url) as resp, open(dest, "wb") as f: # noqa: S310 — pinned URLs
shutil.copyfileobj(resp, f)
return dest
def unzip(zip_path: Path, dest: Path) -> None:
log(f"unzipping {zip_path.name} -> {dest}")
dest.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(zip_path) as zf:
zf.extractall(dest)
def stage_embedded_python() -> Path:
"""Download and configure the embedded Python distribution."""
target = STAGING / "python"
if target.exists():
shutil.rmtree(target)
zip_path = download(
PYTHON_EMBED_URL,
DOWNLOADS / f"python-{PYTHON_VERSION}-embed-amd64.zip",
)
unzip(zip_path, target)
# Edit pythonXY._pth to allow site-packages. The embedded distribution
# ships with `import site` commented out — uncomment it so pip-installed
# packages in Lib\site-packages are importable.
pth_files = list(target.glob("python3*._pth"))
if not pth_files:
raise RuntimeError(f"no python3*._pth file found in {target}")
pth = pth_files[0]
content = pth.read_text()
content = content.replace("#import site", "import site")
# Also add Lib\site-packages explicitly. The embedded distribution
# doesn't include this path by default even with `import site` enabled.
if "Lib\\site-packages" not in content and "Lib/site-packages" not in content:
content = content.rstrip() + "\nLib\\site-packages\n"
pth.write_text(content)
# Bootstrap pip
get_pip = download(GET_PIP_URL, DOWNLOADS / "get-pip.py")
log("bootstrapping pip into embedded Python")
subprocess.run(
[str(target / "python.exe"), str(get_pip), "--no-warn-script-location"],
check=True,
)
# Install setuptools + wheel. The embedded distribution ships without
# them, and get-pip.py installs only pip — but pip needs
# ``setuptools.build_meta`` (PEP 517 backend) to build any source-only
# package. Bambuddy's requirements.txt hits this with pyftpdlib 2.2.0
# which is sdist-only on PyPI; other source-only packages would fail
# the same way without this step.
log("installing setuptools + wheel for PEP 517 builds")
subprocess.run(
[
str(target / "python.exe"),
"-m",
"pip",
"install",
"--no-warn-script-location",
"setuptools",
"wheel",
],
check=True,
)
return target
def stage_vcruntime(python_dir: Path) -> None:
"""Ship the C++ runtime DLLs the embeddable distribution omits.
Placed next to python.exe so the extension-module loader (which searches the
interpreter's own directory) finds them without a redistributable install on
the target machine. Prefers vendored copies under installers/windows/vendor/
for reproducibility; falls back to the build runner's System32, where the
redistributable runtime lives. Fails loudly if neither source has them, so a
misconfigured build machine is caught here instead of by end users.
"""
vendor = INSTALLER_DIR / "vendor"
system32 = Path(os.environ.get("SYSTEMROOT", r"C:\Windows")) / "System32"
for dll in VCRUNTIME_DLLS:
dst = python_dir / dll
if dst.exists():
log(f"{dll} already present in embedded Python")
continue
src = vendor / dll if (vendor / dll).exists() else system32 / dll
if not src.exists():
raise RuntimeError(
f"required C++ runtime DLL not found: looked in {vendor} and {system32} "
f"for {dll}. Install the Microsoft Visual C++ 2015-2022 Redistributable "
f"(x64) on the build machine, or vendor {dll} under installers/windows/vendor/."
)
log(f"staging {dll} from {src}")
shutil.copy(src, dst)
def install_requirements(python_dir: Path) -> None:
"""Install Bambuddy's requirements.txt into the embedded Python."""
py = python_dir / "python.exe"
requirements = REPO_ROOT / "requirements.txt"
log(f"installing requirements.txt into {python_dir}")
subprocess.run(
[
str(py),
"-m",
"pip",
"install",
"--no-warn-script-location",
"-r",
str(requirements),
],
check=True,
)
def build_frontend() -> Path:
"""Run ``npm ci && npm run build`` and return the build output path.
Vite is configured with ``outDir: '../static'`` (see
``frontend/vite.config.ts``), so the bundle lands at ``<repo>/static/``
— NOT ``frontend/dist/``. The path matches the runtime expectation in
``backend/app/core/config.py`` (``static_dir = _app_dir / "static"``).
"""
frontend = REPO_ROOT / "frontend"
dist = REPO_ROOT / "static"
log("running npm ci in frontend/")
npm = shutil.which("npm")
if not npm:
raise RuntimeError("npm not found on PATH — install Node.js 22 LTS")
subprocess.run([npm, "ci"], cwd=frontend, check=True, shell=False)
log("running npm run build in frontend/")
subprocess.run([npm, "run", "build"], cwd=frontend, check=True, shell=False)
if not dist.exists():
raise RuntimeError(f"expected frontend build output at {dist}")
return dist
def stage_backend(frontend_dist: Path) -> None:
"""Copy backend source + frontend bundle into the staging tree.
The runtime layout under STAGING/app/ mirrors a Bambuddy checkout:
``backend/`` (source), ``static/`` (frontend bundle served by FastAPI).
"""
app = STAGING / "app"
if app.exists():
shutil.rmtree(app)
app.mkdir(parents=True)
# Backend source — copy the package tree, skip caches/tests/migrations
log("staging backend source")
shutil.copytree(
REPO_ROOT / "backend",
app / "backend",
ignore=shutil.ignore_patterns(
"__pycache__",
"*.pyc",
"tests",
".pytest_cache",
),
)
# Frontend bundle — FastAPI's StaticFiles mounts from app/static.
# Strip macOS metadata files (.DS_Store, ._.*) that the dev box leaks
# in; they'd just bloat the installer and never be served anyway.
log("staging frontend bundle")
shutil.copytree(
frontend_dist,
app / "static",
ignore=shutil.ignore_patterns(".DS_Store", "._*"),
)
def stage_nssm() -> None:
target = STAGING / "bin"
target.mkdir(parents=True, exist_ok=True)
# Vendored binary — no network fetch at build time
src = INSTALLER_DIR / "vendor" / "nssm.exe"
if not src.exists():
raise RuntimeError(f"vendored NSSM binary missing at {src} — was it committed?")
log(f"staging nssm.exe from {src}")
shutil.copy(src, target / "nssm.exe")
def stage_ffmpeg() -> None:
target = STAGING / "bin"
target.mkdir(parents=True, exist_ok=True)
zip_path = download(FFMPEG_URL, DOWNLOADS / "ffmpeg-win64-gpl.zip")
extract = DOWNLOADS / "ffmpeg-extracted"
if not extract.exists():
unzip(zip_path, extract)
src = next(extract.rglob("bin/ffmpeg.exe"))
log(f"staging ffmpeg.exe from {src}")
shutil.copy(src, target / "ffmpeg.exe")
# ffprobe is used by some camera/timelapse paths
ffprobe = next(extract.rglob("bin/ffprobe.exe"), None)
if ffprobe is not None:
shutil.copy(ffprobe, target / "ffprobe.exe")
def stage_service_scripts() -> None:
"""Copy the service install/uninstall .bat files into staging."""
service_src = INSTALLER_DIR / "service"
service_dst = STAGING / "service"
if service_dst.exists():
shutil.rmtree(service_dst)
shutil.copytree(service_src, service_dst)
def _read_app_version() -> str:
"""Read APP_VERSION from backend/app/core/config.py (the canonical
source used by every other Bambuddy surface — FastAPI OpenAPI title,
/system info, support bundles, spoolbuddy update check).
"""
config_py = REPO_ROOT / "backend" / "app" / "core" / "config.py"
if not config_py.exists():
return "0.0.0+dev"
for raw in config_py.read_text().splitlines():
stripped = raw.strip()
if stripped.startswith("APP_VERSION"):
# APP_VERSION = "0.2.5b1" -> 0.2.5b1
return stripped.split("=", 1)[1].strip().strip('"').strip("'")
return "0.0.0+dev"
def _resolve_installer_version() -> str:
"""Decide what version string the installer carries.
Priority:
1. ``GITHUB_REF`` env var when set to a tag (e.g.
``refs/tags/v0.2.5b1-daily.20260610``) — the daily-beta and stable
publish scripts both push tags in the ``v<APP_VERSION>[-daily.<date>]``
shape, and we want the installer filename + Inno Setup AppVersion
to match the GitHub release exactly so dailies stay distinguishable
from each other and from the eventual stable.
2. ``APP_VERSION`` from config.py for manual workflow_dispatch runs
(no tag) and for local builds.
Strips the leading ``v`` from tags so the installer filename is
``bambuddy-0.2.5b1-daily.20260610-windows-x64-setup.exe``, not
``bambuddy-v0.2.5b1-...``.
"""
ref = os.environ.get("GITHUB_REF", "")
if ref.startswith("refs/tags/"):
tag = ref.removeprefix("refs/tags/")
if tag.startswith("v"):
tag = tag[1:]
return tag or _read_app_version()
return _read_app_version()
def write_version_file() -> None:
"""Write the installer version as both a plain VERSION file and an
Inno Setup include file so the .iss script can pick it up at compile
time without a fragile file-read hack.
"""
version = _resolve_installer_version()
(STAGING / "VERSION").write_text(version)
# Inno Setup include — bambuddy.iss does `#include "build\staging\version.iss"`
iss_version = STAGING / "version.iss"
iss_version.write_text(f'#define MyAppVersion "{version}"\n')
log(f"staged VERSION = {version}")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--skip-frontend",
action="store_true",
help="Skip frontend build (use existing frontend/dist/)",
)
parser.add_argument(
"--skip-pip",
action="store_true",
help="Skip pip install (use existing staged Python)",
)
parser.add_argument(
"--allow-non-windows",
action="store_true",
help=(
"Override the Windows-only guard. Only useful if you have a "
"working wine + windows-python toolchain. Not exercised by CI."
),
)
args = parser.parse_args()
if sys.platform != "win32" and not args.allow_non_windows:
log("ERROR: this build script must run on Windows.")
log("")
log("It downloads a Windows embeddable Python distribution and")
log("pip-installs Bambuddy's requirements.txt against it — both")
log("require executing python.exe, which only runs on Windows.")
log("")
log("Supported build paths:")
log(" 1. GitHub Actions: trigger '.github/workflows/windows-")
log(" installer.yml' (Actions tab -> Windows Installer ->")
log(" Run workflow). Downloads the .exe as a workflow artifact.")
log(" 2. Windows VM / box: clone, install Python 3.13 + Node 22 +")
log(" Inno Setup 6, run this script.")
log("")
log("Unsupported escape hatch (cross-build under Wine): rerun with")
log("--allow-non-windows. Requires wine + a Windows Python in $PATH")
log("via wine python.exe — fragile and not exercised by CI.")
return 1
BUILD_DIR.mkdir(parents=True, exist_ok=True)
DOWNLOADS.mkdir(parents=True, exist_ok=True)
STAGING.mkdir(parents=True, exist_ok=True)
python_dir = stage_embedded_python()
stage_vcruntime(python_dir)
if not args.skip_pip:
install_requirements(python_dir)
if args.skip_frontend:
frontend_dist = REPO_ROOT / "frontend" / "dist"
if not frontend_dist.exists():
raise RuntimeError("--skip-frontend given but frontend/dist/ doesn't exist")
else:
frontend_dist = build_frontend()
stage_backend(frontend_dist)
stage_nssm()
stage_ffmpeg()
stage_service_scripts()
write_version_file()
log("")
log("=" * 60)
log("Staging complete.")
log(f"Staged tree: {STAGING}")
log("")
log("Next: compile the Inno Setup script:")
log(' "C:\\Program Files (x86)\\Inno Setup 6\\ISCC.exe" bambuddy.iss')
log("")
log(f"Installer will be written to: {BUILD_DIR / 'output'}")
log("=" * 60)
return 0
if __name__ == "__main__":
sys.exit(main())