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.
This commit is contained in:
maziggy
2026-08-09 14:10:18 +02:00
parent 2d584e02a4
commit 08df660f6c
64 changed files with 2847 additions and 20974 deletions
+3 -4
View File
@@ -19,12 +19,11 @@ repos:
rev: v5.0.0
hooks:
- id: trailing-whitespace
# Exclude static/ (build output) and gcode_viewer/ (vendored third-party
# assets — see gcode_viewer/VENDORED.md) so whitespace normalisation
# Exclude static/ (build output) so whitespace normalisation
# doesn't drift the files away from upstream.
exclude: ^(static/|gcode_viewer/)
exclude: ^static/
- id: end-of-file-fixer
exclude: ^(static/|gcode_viewer/)
exclude: ^static/
- id: check-yaml
- id: check-json
exclude: ^(static/|frontend/tsconfig\.)
+1
View File
@@ -16,6 +16,7 @@ All notable changes to Bambuddy will be documented in this file.
- **The slice dialog can edit the full print-parameter set, not just pick a preset** — Slicing from Bambuddy meant taking a process preset exactly as it came. Anything beyond that — one more wall for a bracket, supports for a single overhang, slower outer walls on a part that keeps scarring — meant going back to Bambu Studio, editing there, and re-exporting. The slice dialog now has a **Process settings** section carrying the whole tree: the same pages, groups and ordering the desktop slicer shows under Print Settings, with the same labels, tooltips, ranges and defaults, because they are extracted from the slicer's own sources rather than hand-picked. The dialog itself widens to make room: on a reasonably sized screen it now uses two columns, with every "what am I slicing with" decision — pipeline, printer, process, filaments, bed type, layout passes — kept together on the left and the settings panel given a column of its own on the right, open and ready rather than folded away. Narrower screens keep the single column and the collapsed panel. The settings a source file's designer changed (#2622) now live in this panel too, marked *from file* against the options they belong to instead of in a separate list further up the dialog — so there is one place that shows what a slice will actually use. Machine-coupled ones stay flagged and unticked as before, anything the panel has no entry for is listed by name rather than quietly dropped, and typing your own value still wins. Switching on "Use the file's built-in settings" greys the panel out rather than removing it, so the dialog does not appear to lose a feature when that toggle is flipped — it stays visible, says why it is inactive, and applies nothing. Options that select *which* filament prints a feature — support base and interface, and the per-region pickers for walls, infill and surfaces — list the filaments you actually picked on the left rather than asking for a slot number, so "support interface" can be set to the PVA in slot 2 by name. Defaults and ranges are read out of the slicer's C++ initialisers, so a few arrived in source form — the whole Line width group showed "0." rather than "0" — and those are now cleaned as the data is generated instead of being papered over at display time. Every field starts from the values your picked process preset actually sets, fetched by flattening it through the slicer sidecar — the same resolver that does the slicing, so the numbers cannot disagree with what a slice produces. A field you never touch shows the preset's value, and reverting returns to it. Where those values cannot be read the panel falls back to the slicer's own defaults and says why — a sidecar older than the feature, one that did not answer, or none configured at all — rather than presenting the defaults as if they were your preset's. The first is much the most likely, because the sidecar image is pulled independently of your Bambuddy version, so that message names the fix outright: update the sidecar image. It behaves the way the desktop one does. **Simple / Advanced / Expert** matches the slicer's own visibility tiers, search reaches across every page at once, changed settings are marked and individually revertable, and settings the slicer itself disables in your current configuration are greyed out — infill options with infill at zero, ironing options with ironing off — because Bambuddy evaluates the slicer's own enable rules rather than approximating them. Where a rule cannot be decided with certainty the setting stays editable, on the grounds that a missing control looks like a bug while a redundant one is merely ignored. Edits apply to one slice, are not saved into a preset, and are written after the source file's support configuration and any carried designer settings, so an explicit choice is never silently overridden; an untouched panel produces exactly the request it did before. Parameter names and descriptions are in English even where the rest of Bambuddy is not — several hundred strings lifted verbatim from the slicer, which is a separate job from translating Bambuddy's own interface. The dialog's own wording is translated in all locales. Wiki updated, covered by backend and frontend tests.
### Changed
- **The G-code preview is now the slicer's own renderer** — Sliced files previewed through an embedded copy of a third-party viewer, shown in an iframe. It drew each move as a screen-space line, so a print came out stringy and shimmered wherever layers crossed; it coloured by filament slot only; and being a separate app inside a frame, it could be neither themed nor translated, and needed its own machinery to detect a proxy refusing the embed. It has been replaced by Bambuddy's own viewer built on **libvgcode**, the renderer OrcaSlicer draws its own preview with, so extrusions are solid volumes that occlude one another and a print reads the way it does on the desktop. Colour by **filament** — the default, showing the print in the colours you actually assigned — or by **feature**, where walls, infill, supports, bridges and the prime tower each take the slicer's own colour, or by **layer height** or **line width** on a graduated scale. Every entry in the legend is a switch: click a filament or a feature to take it out of the view, which genuinely removes it rather than hiding it behind what it was covering, so you can look inside a part without its supports in the way. The layer slider has both ends, so a band of layers can be isolated rather than only a top capped, and travel moves can be shown. Reading the file needed real care: BambuStudio annotates its G-code quite differently from OrcaSlicer, and it emits a tenth of its moves as arcs — reading only the one dialect showed a 52-layer print as 23,165 layers in a single colour, and ignoring the arcs punched holes through every curved wall and tree support. Both are handled, along with the helical travel lifts that look like arcs but lay down nothing. Covered by frontend tests.
- **The 3D and G-code previews are rendered properly rather than sketched** — The model preview drew every surface with the same flat shading, so a print read as a coloured silhouette with no form, and it sat marooned in the middle of the frame with a screenful of empty space above it. The framing was the plainer bug: the camera distance came from a fixed multiple of the model's largest dimension, which takes no account of the camera's field of view or the shape of the panel it is drawn in, so a tall narrow preview was framed as though it were square. It is now solved against the model's bounding sphere and both fields of view, and fills the frame whatever the panel's proportions. The model itself is lit by a generated environment rather than two lamps and a wash of ambient light, which is what gives a curved surface a gradient across it instead of one flat tone, and it now casts a contact shadow so it looks like it is resting on the plate rather than pasted in front of it. The G-code preview drew each move as a two-pixel line, which is why a sliced model came out stringy and shimmered where layers crossed — a line has no thickness in the scene, so it cannot hide the layer behind it. Moves are now drawn as solid extrusions with real width and height, and the print occludes itself the way it does in a desktop slicer. The modal's second tab is gone: G-code already has its own full-page viewer, and a preview of a model is a different question from a preview of a print. Covered by frontend tests.
- **The slice dialog's process and filament lists now leave out presets that belong to another printer** — They were already sorted by compatibility, but a preset for a different Bambu model still appeared, demoted to an "Other printers" group at the bottom of the dropdown. With a large cloud filament library that group is most of the list, so the filtering was doing little for the thing it was meant to help: finding the profile you actually want. Those presets are now held back, with the label reporting how many ("3 hidden") next to a **Show all** link that brings them back for that one dropdown. Two things are never hidden. A preset with no detectable printer — a custom or renamed profile — stays in the list, because absence of evidence is not evidence of incompatibility and hiding those would make people's own imported profiles vanish. And whatever is currently selected stays visible even when the list is collapsed, so a deliberate cross-printer pick, or one restored from a pipeline, is never silently discarded by being dropped from the options. Re-slicing for another printer remains fully supported, so this is a default view rather than a restriction. Fixing this also corrected a screen-reader bug in those dropdowns: the controls sat inside the label wrapping the select, which handed them the entire label as their spoken name. A separate defect surfaced alongside it — the filter did nothing at all when the selected printer was a preset you had edited, because BambuStudio names those copies with a leading "# " and the matcher did not know to look past it. On such a printer every preset read as "compatibility unknown", and profiles listing their compatible printers by name could be ruled out against the very printer they were cloned from.
- **The MQTT debug log now records the commands sent to a printer, not only what it reports back** — **Printer → Debug → MQTT** captured one side of the conversation. Bambuddy listens on both of a printer's topics, but the one carrying commands returned before anything was written to the log, so a capture could show every status push the printer made and nothing it was ever told — including the commands Bambu Studio sends over the local network, which is the only place they can be observed at all. Those now appear alongside Bambuddy's own, grouped under the outgoing filter. It is what lets a question like "which value does Studio put in this field?" be answered from a user's capture instead of guessed at, and it is why #2774 could not be taken further. Commands Bambuddy sends appear twice, once as it publishes and once as the broker echoes it back, and the pair is itself evidence the command reached the broker. Logging is off until switched on, as before. Covered by backend tests.
-9
View File
@@ -72,15 +72,6 @@ COPY .git/HEAD ./.git/HEAD
# Copy built frontend from builder stage
COPY --from=frontend-builder /app/static ./static
# Copy embedded GCode viewer static assets (PrettyGCode + Bambuddy adapter).
# Served by the explicit @app.get("/gcode-viewer/{...}") routes in main.py,
# which resolve files under (static_dir.parent / "gcode_viewer") = /app/gcode_viewer/.
# Without this COPY the routes return a bare 404 at request time and the 3D
# Preview iframe shows {"detail":"Not Found"} (see #1218). The directory is
# vendored third-party JS — the Vite build does NOT stage it into static/,
# the dev server serves it via a configureServer middleware that's dev-only.
COPY gcode_viewer/ ./gcode_viewer/
# Create data directories. Ownership is normalised at startup by the
# entrypoint (chowns to PUID:PGID and drops privileges via gosu before
# exec'ing the app), so we don't need a chmod 777 hack here — that was
-5
View File
@@ -23,11 +23,6 @@ RUN --mount=type=cache,target=/root/.cache/pip \
COPY backend/ ./backend/
COPY pyproject.toml ./
# Embedded GCode viewer assets — required so the @app.get("/gcode-viewer/...")
# packaging-regression test in tests/integration/test_gcode_viewer.py actually
# runs instead of pytest-skipping with "index.html not present". Path matches
# the production Dockerfile (static_dir.parent / "gcode_viewer" = /app/gcode_viewer/).
COPY gcode_viewer/ ./gcode_viewer/
# Create necessary directories
RUN mkdir -p /app/data /app/logs /app/archive
+5 -63
View File
@@ -1,7 +1,6 @@
import asyncio
import json
import logging
import mimetypes as _mimetypes
import os
import posixpath
import secrets
@@ -8063,7 +8062,8 @@ def _frame_ancestors(default_value: str) -> str:
``default_value`` is the strict directive used when the operator has not
configured ``TRUSTED_FRAME_ORIGINS`` — typically ``'none'`` (catch-all and
docs) or ``'self'`` (gcode-viewer, served same-origin). When trusted origins
docs) or ``'self'`` (the streaming overlay, embedded same-origin by the
Settings URL builder's preview). When trusted origins
are configured, ``'self'`` is always included so same-origin embedding never
breaks even if an operator forgets to add their own origin to the list.
"""
@@ -8103,23 +8103,7 @@ async def security_headers_middleware(request, call_next):
# - img-src data: / blob:: base64 thumbnails and Blob-URL timelapse previews.
# - media-src blob:: timelapse video player uses Blob URLs.
# - font-src data:: some icon fonts are embedded as data URIs.
if request.url.path.startswith("/gcode-viewer"):
# The gcode viewer is embedded in an iframe served by this same origin,
# so frame-ancestors must allow 'self'. prettygcode.js also uses eval()
# internally, so script-src needs 'unsafe-eval'.
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self' 'unsafe-eval'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: blob:; "
"media-src 'self' blob:; "
"connect-src 'self' ws: wss:; "
"font-src 'self' data:; "
"object-src 'none'; "
"base-uri 'self'; "
"frame-src 'self' http: https:; " + _frame_ancestors("'self'")
)
elif request.url.path in ("/docs", "/redoc", "/docs/oauth2-redirect"):
if request.url.path in ("/docs", "/redoc", "/docs/oauth2-redirect"):
# FastAPI's built-in Swagger UI / ReDoc pages load assets from
# cdn.jsdelivr.net and bootstrap with an inline <script>, so the
# default CSP would render a blank page.
@@ -8136,8 +8120,8 @@ async def security_headers_middleware(request, call_next):
)
else:
# The streaming overlay is embedded same-origin by the URL builder's
# preview in Settings (#1422) — the same reason /gcode-viewer allows
# 'self' above. Embedding from anywhere else is still refused: 'self'
# preview in Settings (#1422), so this branch allows 'self'.
# Embedding from anywhere else is still refused: 'self'
# only permits a framer on this origin, which is Bambuddy's own UI, so
# a clickjacking page on another host is blocked exactly as before.
# (The overlay draws status over a camera feed and its only interactive
@@ -8507,48 +8491,6 @@ async def serve_sw_register():
# ── GCode viewer static files ────────────────────────────────────────────────
# Served via explicit routes so ordering is guaranteed (app.mount() loses
# to the /{full_path:path} catch-all in some Starlette versions).
_gcode_viewer_dir = (app_settings.static_dir.parent / "gcode_viewer").resolve()
# Surface packaging gaps at startup instead of as silent runtime 404s. If the
# directory is missing the explicit @app.get("/gcode-viewer/...") routes below
# return bare HTTPException(404) which renders as {"detail":"Not Found"} in
# the 3D Preview iframe (#1218) — easy to miss in normal operation, easy to
# spot if the operator scans the startup log or a support bundle.
if not (_gcode_viewer_dir / "index.html").is_file():
logging.getLogger(__name__).error(
"Embedded GCode viewer assets missing at %s — /gcode-viewer/ will return 404 "
"and 3D Preview will fail. This indicates a packaging bug; the gcode_viewer/ "
"directory must be present alongside static/.",
_gcode_viewer_dir,
)
def _gcode_viewer_response(rel: str) -> FileResponse:
from fastapi import HTTPException as _HTTPException
safe = (_gcode_viewer_dir / rel).resolve()
if not safe.is_relative_to(_gcode_viewer_dir):
raise _HTTPException(status_code=403)
if safe.is_file():
mt, _ = _mimetypes.guess_type(str(safe))
return FileResponse(str(safe), media_type=mt or "application/octet-stream")
raise _HTTPException(status_code=404)
@app.get("/gcode-viewer/")
async def serve_gcode_viewer_index() -> FileResponse:
"""Raw PrettyGCode viewer for the iframe. The bare ``/gcode-viewer``
(no trailing slash) intentionally falls through to the SPA catch-all so a
full-page reload re-enters the React layout instead of serving the iframe
contents standalone."""
return _gcode_viewer_response("index.html")
@app.get("/gcode-viewer/{file_path:path}")
async def serve_gcode_viewer_file(file_path: str) -> FileResponse:
return _gcode_viewer_response(file_path)
# Catch-all route for React Router (must be last)
@@ -1,346 +0,0 @@
"""Integration tests for the /gcode-viewer static-file routes.
Covers two behaviours added by the GCode viewer PR:
1. Route ordering — /gcode-viewer/* is served by explicit @app.get routes
that are registered before the /{full_path:path} SPA catch-all, so the
GCode viewer is never accidentally served the React app HTML.
2. Path-traversal guard — requests for paths that escape gcode_viewer/
(e.g. /gcode-viewer/../main.py) must return 403, not the file contents.
Plus tests for the archive G-code endpoint behaviour the viewer depends on:
``?plate=N`` resolution including zero-padded filenames, and the ``has_gcode``
flag on the plates endpoint that gates the frontend plate picker.
"""
import zipfile
from pathlib import Path
import pytest
from httpx import AsyncClient
class TestGCodeViewerRouteOrdering:
"""Verify the /gcode-viewer routes are reachable and distinct from the SPA."""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_gcode_viewer_index_does_not_fall_through_to_spa(self, async_client: AsyncClient):
"""GET /gcode-viewer/ must not return the React SPA index.html.
If route ordering is broken the SPA catch-all returns 200 with
Content-Type: text/html and a <div id="root"> body. The correct
response is either 200 (gcode_viewer/index.html present) or 404
(directory absent in CI) — never the SPA shell.
"""
response = await async_client.get("/gcode-viewer/")
# 200 or 404 are both acceptable depending on whether gcode_viewer/
# exists in the test environment; the SPA catch-all always returns 200.
assert response.status_code in (200, 404)
# If a body came back it must NOT be the React SPA shell.
assert b'<div id="root">' not in response.content
@pytest.mark.asyncio
@pytest.mark.integration
async def test_gcode_viewer_index_served_when_assets_present(self, async_client: AsyncClient):
"""GET /gcode-viewer/ must return the PrettyGCode index when the
vendored assets are on disk.
Regression guard for #1218: the production Dockerfile has to copy
``gcode_viewer/`` into the image alongside ``static/``. The previous
``test_gcode_viewer_index_does_not_fall_through_to_spa`` accepted
404 unconditionally so a missing COPY never failed CI. This test
only runs when the directory is actually present (so it stays a
no-op in unit-test environments where the assets are intentionally
absent), but when it does run it asserts 200 + a non-empty HTML
body so a future packaging regression fails loudly.
"""
from backend.app.main import _gcode_viewer_dir
index = _gcode_viewer_dir / "index.html"
if not index.is_file():
pytest.skip(f"gcode_viewer/index.html not present at {index} — skipping packaging assertion")
response = await async_client.get("/gcode-viewer/")
assert response.status_code == 200, (
f"gcode_viewer/index.html exists at {index} but /gcode-viewer/ returned "
f"{response.status_code} — route or response wiring is broken."
)
assert b"PrettyGCode" in response.content or b"<!doctype html>" in response.content.lower()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_gcode_viewer_no_trailing_slash_falls_through_to_spa(self, async_client: AsyncClient):
"""GET /gcode-viewer (no trailing slash) must fall through to the SPA.
Only /gcode-viewer/ (trailing slash) should serve the raw viewer — that
form is what the iframe in GCodeViewerPage requests. The bare path is
the SPA route the user navigates to; reloading it must re-enter the
React layout rather than serve the iframe contents standalone.
"""
response = await async_client.get("/gcode-viewer", follow_redirects=False)
# SPA catch-all serves 200 with the React index.html (which contains
# <div id="root">). If the build output isn't present the catch-all
# may 404 — both outcomes are acceptable here; the key invariant is
# that we do NOT serve the standalone PrettyGCode index.html (which
# starts with <!doctype html> and contains "PrettyGCode").
assert response.status_code in (200, 404)
if response.status_code == 200:
assert b"PrettyGCode" not in response.content
class TestGCodeViewerPathTraversal:
"""Verify the path-traversal guard on /gcode-viewer/{file_path:path}.
HTTP clients (and servers) normalise plain `..` segments before the
request reaches a route handler, so `/gcode-viewer/../x` becomes `/x`
and hits the SPA catch-all rather than our guard — that normalisation is
itself a defence layer. The actual at-risk form is URL-encoded dots
(`%2E%2E`) which survive normalisation and land in {file_path:path} as
the literal string `../x`. We test that form here.
"""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_encoded_dotdot_traversal_is_forbidden(self, async_client: AsyncClient):
"""GET /gcode-viewer/%2E%2E/main.py must return 403.
%2E%2E URL-decodes to .. which is not normalised away by httpx/
Starlette, so it reaches _gcode_viewer_response as '../main.py'.
Path.is_relative_to(gcode_viewer_dir) then blocks it with 403.
"""
response = await async_client.get("/gcode-viewer/%2E%2E/main.py")
assert response.status_code == 403
@pytest.mark.asyncio
@pytest.mark.integration
async def test_encoded_nested_dotdot_traversal_is_forbidden(self, async_client: AsyncClient):
"""GET /gcode-viewer/js/%2E%2E/%2E%2E/main.py must return 403."""
response = await async_client.get("/gcode-viewer/js/%2E%2E/%2E%2E/main.py")
assert response.status_code == 403
@pytest.mark.asyncio
@pytest.mark.integration
async def test_nonexistent_safe_path_returns_404(self, async_client: AsyncClient):
"""A safe but nonexistent path returns 404, not 403."""
response = await async_client.get("/gcode-viewer/does-not-exist.js")
assert response.status_code == 404
def _write_3mf(
path: Path,
plate_gcode: dict[int, str] | None = None,
plate_filenames: dict[int, str] | None = None,
include_png_for: list[int] | None = None,
) -> None:
"""Write a synthetic Bambu-style 3MF zip at *path*.
Parameters let a single test pin one specific shape:
- ``plate_gcode`` — {plate_index: gcode_text} written at
``Metadata/plate_{index}.gcode``. Use for the normal (sliced) case.
- ``plate_filenames`` — {plate_index: custom_filename} written with the
raw filename verbatim. Use for zero-padded names (plate_01.gcode) etc.
- ``include_png_for`` — plate indices to add PNG stubs for. Use to
simulate source-only archives (PNG/JSON present, no .gcode).
Leaving all three empty produces an archive that the plates endpoint
will parse as empty (no plates).
"""
plate_gcode = plate_gcode or {}
plate_filenames = plate_filenames or {}
include_png_for = include_png_for or []
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
for idx, text in plate_gcode.items():
zf.writestr(f"Metadata/plate_{idx}.gcode", text)
for idx, filename in plate_filenames.items():
zf.writestr(f"Metadata/{filename}", f"; stub for plate {idx}\n")
for idx in include_png_for:
zf.writestr(f"Metadata/plate_{idx}.png", b"\x89PNG\r\n\x1a\n")
zf.writestr(f"Metadata/plate_{idx}.json", b'{"bbox_objects": []}')
@pytest.fixture
def _patch_archive_base_dir(monkeypatch, tmp_path):
"""Point archive file_path resolution at *tmp_path* for this test."""
from backend.app.core.config import settings
monkeypatch.setattr(settings, "base_dir", tmp_path)
return tmp_path
class TestArchiveGcodePlateParam:
"""The viewer passes ``?plate=N`` for multi-plate archives."""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_plate_param_returns_that_plate(
self,
async_client: AsyncClient,
archive_factory,
printer_factory,
_patch_archive_base_dir,
):
"""GET /archives/{id}/gcode?plate=2 returns Metadata/plate_2.gcode."""
tmp = _patch_archive_base_dir
threemf = tmp / "multi.3mf"
_write_3mf(
threemf,
plate_gcode={1: "G0 ; plate 1\n", 2: "G1 X0 Y0 ; plate 2\n"},
)
printer = await printer_factory()
archive = await archive_factory(printer.id, filename="multi.3mf", file_path="multi.3mf")
response = await async_client.get(f"/api/v1/archives/{archive.id}/gcode?plate=2")
assert response.status_code == 200
assert "plate 2" in response.text
@pytest.mark.asyncio
@pytest.mark.integration
async def test_plate_param_zero_padded_filename_resolves(
self,
async_client: AsyncClient,
archive_factory,
printer_factory,
_patch_archive_base_dir,
):
"""plate_01.gcode reports as plate 1 from /plates — /gcode?plate=1 must find it.
Regression: the original exact-string match on ``Metadata/plate_1.gcode``
missed zero-padded filenames exported by some slicers, so the picker
showed plate 1 as selectable but the viewer 404'd on selection.
"""
tmp = _patch_archive_base_dir
threemf = tmp / "padded.3mf"
with zipfile.ZipFile(threemf, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("Metadata/plate_01.gcode", "G0 ; padded plate\n")
printer = await printer_factory()
archive = await archive_factory(printer.id, filename="padded.3mf", file_path="padded.3mf")
response = await async_client.get(f"/api/v1/archives/{archive.id}/gcode?plate=1")
assert response.status_code == 200
assert "padded plate" in response.text
@pytest.mark.asyncio
@pytest.mark.integration
async def test_missing_plate_returns_404(
self,
async_client: AsyncClient,
archive_factory,
printer_factory,
_patch_archive_base_dir,
):
"""Requesting a plate index the archive doesn't contain returns 404."""
tmp = _patch_archive_base_dir
threemf = tmp / "only_plate_2.3mf"
_write_3mf(threemf, plate_gcode={2: "G0\n"})
printer = await printer_factory()
archive = await archive_factory(printer.id, filename="only_plate_2.3mf", file_path="only_plate_2.3mf")
response = await async_client.get(f"/api/v1/archives/{archive.id}/gcode?plate=1")
assert response.status_code == 404
@pytest.mark.asyncio
@pytest.mark.integration
async def test_no_plate_param_returns_first_plate(
self,
async_client: AsyncClient,
archive_factory,
printer_factory,
_patch_archive_base_dir,
):
"""Omitting ?plate falls back to the first gcode in the archive.
Preserves the pre-plate-param behaviour — existing callers that don't
know about plates still get something sensible back.
"""
tmp = _patch_archive_base_dir
threemf = tmp / "single.3mf"
_write_3mf(threemf, plate_gcode={1: "G0 ; only plate\n"})
printer = await printer_factory()
archive = await archive_factory(printer.id, filename="single.3mf", file_path="single.3mf")
response = await async_client.get(f"/api/v1/archives/{archive.id}/gcode")
assert response.status_code == 200
assert "only plate" in response.text
@pytest.mark.asyncio
@pytest.mark.integration
async def test_plate_param_rejects_zero_and_negative(
self,
async_client: AsyncClient,
archive_factory,
printer_factory,
_patch_archive_base_dir,
):
"""``?plate=0`` or negative must 400 — not silently fall through."""
tmp = _patch_archive_base_dir
threemf = tmp / "any.3mf"
_write_3mf(threemf, plate_gcode={1: "G0\n"})
printer = await printer_factory()
archive = await archive_factory(printer.id, filename="any.3mf", file_path="any.3mf")
response = await async_client.get(f"/api/v1/archives/{archive.id}/gcode?plate=0")
assert response.status_code == 400
class TestArchivePlatesHasGcode:
"""The ``has_gcode`` flag on /plates gates the frontend plate picker."""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_has_gcode_true_when_gcode_files_present(
self,
async_client: AsyncClient,
archive_factory,
printer_factory,
_patch_archive_base_dir,
):
"""Sliced multi-plate 3MF → has_gcode=true."""
tmp = _patch_archive_base_dir
threemf = tmp / "sliced.3mf"
_write_3mf(threemf, plate_gcode={1: "G0\n", 2: "G1\n"})
printer = await printer_factory()
archive = await archive_factory(printer.id, filename="sliced.3mf", file_path="sliced.3mf")
response = await async_client.get(f"/api/v1/archives/{archive.id}/plates")
assert response.status_code == 200
data = response.json()
assert data["has_gcode"] is True
@pytest.mark.asyncio
@pytest.mark.integration
async def test_has_gcode_false_for_source_only_archive(
self,
async_client: AsyncClient,
archive_factory,
printer_factory,
_patch_archive_base_dir,
):
"""Source-only 3MF (PNG/JSON only, no gcode) → has_gcode=false.
Regression for the archive-69 bug: the PNG/JSON fallback path made the
plates endpoint report plate indices that the gcode endpoint couldn't
actually serve, so every viewer preview 404'd. The frontend now uses
has_gcode to suppress the picker + show a toast instead.
"""
tmp = _patch_archive_base_dir
threemf = tmp / "project.3mf"
_write_3mf(threemf, include_png_for=[1, 2, 3]) # no .gcode at all
printer = await printer_factory()
archive = await archive_factory(printer.id, filename="project.3mf", file_path="project.3mf")
response = await async_client.get(f"/api/v1/archives/{archive.id}/plates")
assert response.status_code == 200
data = response.json()
assert data["has_gcode"] is False
# The endpoint still reports plates (from JSON/PNG) — the flag is what
# the frontend keys on, not an empty plate list.
assert len(data["plates"]) == 3
@@ -1,8 +1,9 @@
"""Integration tests for security_headers_middleware (#1191).
Default behaviour is strict: ``X-Frame-Options: SAMEORIGIN`` plus
``frame-ancestors 'none'`` on the catch-all route, ``frame-ancestors 'self'``
on /gcode-viewer/. Operators can opt into iframe embedding from trusted
``frame-ancestors 'none'`` on the catch-all route, and ``frame-ancestors
'self'`` on the streaming overlay, which the Settings URL builder previews
same-origin. Operators can opt into iframe embedding from trusted
origins (e.g. Home Assistant on a different port) via the
``TRUSTED_FRAME_ORIGINS`` env var; when set, X-Frame-Options is dropped and
``frame-ancestors`` includes the allowlist.
@@ -36,7 +36,7 @@ FLAG = "--timeout-graceful-shutdown"
# These pin repo-root launcher files (Dockerfile, compose, service units,
# install scripts) that the Docker test image deliberately does not ship —
# Dockerfile.test copies only backend/, pyproject.toml, gcode_viewer/ and
# Dockerfile.test copies only backend/, pyproject.toml and
# requirements. In a source checkout the files are always present and the
# guard below is live (a moved/deleted launcher still fails loudly on every
# `test_backend.sh` run); inside the stripped test image there is nothing to
@@ -114,8 +114,6 @@ _PUBLIC_ROUTES: frozenset[tuple[str, str]] = frozenset(
("GET", "/manifest.json"),
("GET", "/sw-register.js"),
("GET", "/sw.js"),
("GET", "/gcode-viewer/"),
("GET", "/gcode-viewer/{file_path:path}"),
# SPA catch-all — serves index.html for client-side routing. No backend data path.
("GET", "/{full_path:path}"),
# ---- WebSocket routes ----
@@ -22,7 +22,7 @@ INSTALLERS = ["install/install.sh", "spoolbuddy/install/install.sh"]
# The service unit + install scripts these tests read live at the repo root and
# are not copied into the Docker test image (Dockerfile.test ships only backend/,
# pyproject.toml, gcode_viewer/ and requirements). In a source checkout they are
# pyproject.toml and requirements). In a source checkout they are
# always present and the guard below is live; in the stripped test image there is
# nothing to check, so skip rather than fail. `frontend/package.json` exists in
# every checkout but never in the test image, so it distinguishes the two.
+4 -1
View File
@@ -6,7 +6,10 @@ import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist', 'coverage']),
// src/lib/vendor holds third-party build output copied in verbatim. Linting
// it produces findings we must not act on -- editing vendored code makes it
// impossible to re-copy on the next upstream release.
globalIgnores(['dist', 'coverage', 'src/lib/vendor']),
{
files: ['**/*.{ts,tsx}'],
extends: [
-23
View File
@@ -23,7 +23,6 @@
"@tiptap/starter-kit": "^3.11.1",
"@types/three": "^0.181.0",
"dompurify": "^3.4.10",
"gcode-preview": "^2.18.0",
"i18next": "25.6.3",
"i18next-browser-languagedetector": "^8.2.0",
"jszip": "^3.10.1",
@@ -4300,22 +4299,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gcode-preview": {
"version": "2.18.0",
"resolved": "https://registry.npmjs.org/gcode-preview/-/gcode-preview-2.18.0.tgz",
"integrity": "sha512-uc9QYciG6ES/A6BWJpUZk4fHxCPvt5EnvDhHIHDbNdR/m3f9VkGvpSMh9HDygXjAXX0x1Lbz/e9ZGlIrYNB29A==",
"license": "MIT",
"dependencies": {
"lil-gui": "^0.19.2",
"three": "^0.159.0"
}
},
"node_modules/gcode-preview/node_modules/three": {
"version": "0.159.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.159.0.tgz",
"integrity": "sha512-eCmhlLGbBgucuo4VEA9IO3Qpc7dh8Bd4VKzr7WfW4+8hMcIfoAVi1ev0pJYN9PTTsCslbcKgBwr2wNZ1EvLInA==",
"license": "MIT"
},
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -5326,12 +5309,6 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lil-gui": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/lil-gui/-/lil-gui-0.19.2.tgz",
"integrity": "sha512-nU8j4ND702ouGfQZoaTN4dfXxacvGOAVK0DtmZBVcUYUAeYQXLQAjAN50igMHiba3T5jZyKEjXZU+Ntm1Qs6ZQ==",
"license": "MIT"
},
"node_modules/linkify-it": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz",
-1
View File
@@ -30,7 +30,6 @@
"@tiptap/starter-kit": "^3.11.1",
"@types/three": "^0.181.0",
"dompurify": "^3.4.10",
"gcode-preview": "^2.18.0",
"i18next": "25.6.3",
"i18next-browser-languagedetector": "^8.2.0",
"jszip": "^3.10.1",
+2
View File
@@ -221,6 +221,7 @@ const FR_COGNATES = [
'ntfy, Pushover, Discord, etc.',
'{{filament}} @ {{temp}}°C', // drying badge: filament code + universal °C
'Simple', 'Expert', // slicer settings visibility tiers — identical words in French
'Support', // same word in French
];
// Italian cognates.
@@ -255,6 +256,7 @@ const IT_COGNATES = [
'Proxy', 'Designer',
'Off', // cam-wall status overlay mode — common loanword in Italian UI
'{{filament}} @ {{temp}}°C', // drying badge: filament code + universal °C
'Skirt / brim', // Italian slicer UIs keep the English terms
];
// Japanese: very few cognates because of script difference. Almost
@@ -12,7 +12,7 @@ import { openInSlicer } from '../../utils/slicer';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
// Mock ModelViewer and GcodeViewer to avoid WebGL/Three.js issues in tests
// Mock ModelViewer to avoid WebGL/Three.js issues in tests
vi.mock('../../components/ModelViewer', () => ({
ModelViewer: ({ className }: { className?: string }) => (
<div data-testid="model-viewer" className={className}>
@@ -21,14 +21,6 @@ vi.mock('../../components/ModelViewer', () => ({
),
}));
vi.mock('../../components/GcodeViewer', () => ({
GcodeViewer: ({ className }: { className?: string }) => (
<div data-testid="gcode-viewer" className={className}>
G-code Viewer Mock
</div>
),
}));
// Only the protocol-handler launch is stubbed — it would navigate the jsdom
// window. Everything else in the module is a pure predicate, so keep the real
// implementations: re-declaring them here would let the file-type rule these
@@ -1,120 +1,139 @@
/**
* The G-code viewer's frame, when something refuses to let it be embedded (#2787).
* The full-page G-code preview.
*
* Sliced files preview through a full-page route whose body is an iframe of
* /gcode-viewer/; STL and source 3MF use an in-page three.js modal instead. So a
* proxy that injects a framing header breaks exactly one of the two previews,
* and all the user sees is the browser's own "refused to connect" page inside
* our layout shell — no clue what happened, and no hint that the viewer works
* perfectly well in a tab of its own.
* This used to be an iframe onto a vendored copy of PrettyGCode, and most of
* the page was machinery for detecting when a proxy refused the embed. It now
* renders Bambuddy's own toolpath viewer directly, so what is worth testing is
* that the right file reaches it -- including the plate, which a multi-plate
* archive needs or the viewer silently shows a different plate than the one
* that was picked.
*/
import { describe, it, expect } from 'vitest';
import { screen, waitFor, within } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { render } from '../utils';
import { GCodeViewerPage } from '../../pages/GCodeViewerPage';
import { findFramingRefusal } from '../../utils/framing';
import { server } from '../mocks/server';
import { GCodeViewerPage } from '../../pages/GCodeViewerPage';
const ORIGIN = 'https://printers.example.com';
const OURS = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'self';";
// The viewer itself needs WebGL, which jsdom has no answer for. Its own
// behaviour is covered by the parser and renderer tests; here we only care
// which URL the page hands it.
vi.mock('../../components/GcodeToolpathViewer', () => ({
GcodeToolpathViewer: ({ gcodeUrl, filamentColors }: { gcodeUrl: string; filamentColors?: string[] }) => (
<div data-testid="toolpath-viewer" data-url={gcodeUrl} data-colors={(filamentColors ?? []).join(',')} />
),
}));
function serveViewer(status: number, headers: Record<string, string> = {}) {
server.use(http.get('/gcode-viewer/', () => new HttpResponse(null, { status, headers })));
function visit(search: string) {
window.history.pushState({}, '', `/gcode-viewer${search}`);
render(<GCodeViewerPage />);
}
describe('findFramingRefusal', () => {
it('accepts the headers Bambuddy itself sends', () => {
expect(findFramingRefusal('SAMEORIGIN', OURS, ORIGIN)).toBeNull();
const viewerUrl = () => screen.getByTestId('toolpath-viewer').getAttribute('data-url');
describe('GCodeViewerPage', () => {
const originalUrl = window.location.href;
beforeEach(() => {
window.history.pushState({}, '', '/gcode-viewer');
});
it('accepts an origin named explicitly instead of self', () => {
const csp = `frame-ancestors ${ORIGIN};`;
expect(findFramingRefusal(null, csp, ORIGIN)).toBeNull();
afterEach(() => {
window.history.pushState({}, '', originalUrl);
});
it('reports a proxy-added policy that intersects ours down to none', () => {
// Two Content-Security-Policy headers arrive as one comma-joined string.
// Both apply, so ours permitting us is not enough.
const refusal = findFramingRefusal('SAMEORIGIN', `${OURS}, frame-ancestors 'none'`, ORIGIN);
expect(refusal).toBe("Content-Security-Policy: frame-ancestors 'none'");
it('previews an archive', () => {
visit('?archive=82');
expect(viewerUrl()).toContain('/archives/82/gcode');
});
it('reports frame-ancestors listing only somebody else', () => {
const refusal = findFramingRefusal(null, "frame-ancestors https://ha.example.com;", ORIGIN);
expect(refusal).toContain('ha.example.com');
it('previews a library file', () => {
visit('?library_file=7');
expect(viewerUrl()).toContain('/library/files/7/gcode');
});
it('reports X-Frame-Options DENY when no frame-ancestors is present', () => {
expect(findFramingRefusal('DENY', null, ORIGIN)).toBe('X-Frame-Options: DENY');
it('carries the plate through for a multi-plate source', () => {
// Dropping this shows whichever plate the backend defaults to rather than
// the one the user picked, with nothing on screen to say so.
visit('?archive=82&plate=3');
expect(viewerUrl()).toContain('plate=3');
});
it('reports a second X-Frame-Options appended to ours', () => {
expect(findFramingRefusal('SAMEORIGIN, DENY', null, ORIGIN)).toBe(
'X-Frame-Options: SAMEORIGIN, DENY',
it('omits the plate parameter when there is no plate', () => {
visit('?archive=82');
expect(viewerUrl()).not.toContain('plate=');
});
it('says so when no file was given rather than rendering an empty viewer', () => {
visit('');
expect(screen.queryByTestId('toolpath-viewer')).not.toBeInTheDocument();
expect(screen.getByText(/No file was given/i)).toBeInTheDocument();
});
it('offers a way back to where the file came from', () => {
visit('?archive=82');
expect(screen.getByRole('button', { name: /Back to Print Archives/i })).toBeInTheDocument();
});
it('names the file manager when the source was a library file', () => {
visit('?library_file=7');
expect(screen.getByRole('button', { name: /Back to File Manager/i })).toBeInTheDocument();
});
});
describe('GCodeViewerPage — filament colours', () => {
const originalUrl = window.location.href;
afterEach(() => window.history.pushState({}, '', originalUrl));
it('indexes library-file colours by tool number, not slot number', async () => {
// slot_id is 1-based and the G-code's T numbers are 0-based; indexing
// straight by slot puts every colour one filament out, so a two-material
// print comes out with the wrong body colour.
server.use(
http.get('/api/v1/library/files/:id/plates', () =>
HttpResponse.json({
file_id: 7,
filename: 'duck.gcode.3mf',
is_multi_plate: false,
plates: [
{
index: 1,
name: null,
objects: [],
has_thumbnail: false,
thumbnail_url: null,
print_time_seconds: null,
filament_used_grams: null,
filaments: [
{ slot_id: 1, type: 'PLA', color: '#ffffff', used_grams: 1, used_meters: 1 },
{ slot_id: 2, type: 'PLA', color: '#000000', used_grams: 1, used_meters: 1 },
],
},
],
}),
),
);
window.history.pushState({}, '', '/gcode-viewer?library_file=7');
render(<GCodeViewerPage />);
await waitFor(() =>
expect(screen.getByTestId('toolpath-viewer')).toHaveAttribute('data-colors', '#ffffff,#000000'),
);
});
it('ignores X-Frame-Options when frame-ancestors permits us, as browsers do', () => {
// CSP supersedes the legacy header outright — flagging this would blame a
// header the browser never consulted.
expect(findFramingRefusal('DENY', OURS, ORIGIN)).toBeNull();
});
it('previews without colours when the plate metadata carries none', async () => {
server.use(
http.get('/api/v1/library/files/:id/plates', () =>
HttpResponse.json({ file_id: 7, filename: 'x', is_multi_plate: false, plates: [] }),
),
);
it('accepts a response carrying no framing headers at all', () => {
expect(findFramingRefusal(null, null, ORIGIN)).toBeNull();
});
});
describe('GCodeViewerPage', () => {
it('embeds the viewer when nothing refuses the frame', async () => {
serveViewer(200, { 'X-Frame-Options': 'SAMEORIGIN', 'Content-Security-Policy': OURS });
render(<GCodeViewerPage />);
expect(await screen.findByTitle('GCode Viewer')).toBeInTheDocument();
// Give the probe a chance to land and prove it changes nothing.
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument());
expect(screen.getByTitle('GCode Viewer')).toBeInTheDocument();
});
it('explains a refused frame and offers the viewer in its own tab', async () => {
serveViewer(200, { 'Content-Security-Policy': "frame-ancestors 'none';" });
render(<GCodeViewerPage />);
const panel = await screen.findByRole('alert');
expect(panel).toHaveTextContent(/could not be embedded/i);
// Name the header so the operator can go and find it in their proxy.
expect(panel).toHaveTextContent(/frame-ancestors 'none'/);
// A top-level navigation is not subject to frame-ancestors, so this works.
const link = within(panel).getByRole('link', { name: /new tab/i });
expect(link).toHaveAttribute('href', '/gcode-viewer/');
expect(link).toHaveAttribute('target', '_blank');
expect(screen.queryByTitle('GCode Viewer')).not.toBeInTheDocument();
});
it('reports missing viewer assets rather than showing raw JSON', async () => {
serveViewer(404);
render(<GCodeViewerPage />);
const panel = await screen.findByRole('alert');
expect(panel).toHaveTextContent(/unavailable/i);
expect(panel).toHaveTextContent(/HTTP 404/);
expect(screen.queryByTitle('GCode Viewer')).not.toBeInTheDocument();
});
it('keeps the frame when the probe itself fails', async () => {
// No evidence either way — the browser's own error page is better than a
// guess at a cause we cannot see.
server.use(http.get('/gcode-viewer/', () => HttpResponse.error()));
window.history.pushState({}, '', '/gcode-viewer?library_file=7');
render(<GCodeViewerPage />);
expect(await screen.findByTitle('GCode Viewer')).toBeInTheDocument();
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument());
expect(screen.getByTitle('GCode Viewer')).toBeInTheDocument();
// The preview must still render; colours are a bonus, not a prerequisite.
expect(screen.getByTestId('toolpath-viewer')).toBeInTheDocument();
});
});
@@ -0,0 +1,466 @@
import { describe, it, expect } from 'vitest';
import * as THREE from 'three';
import { parseGcodeToolpath, layersByFilament, filterLayersByType, ToolpathType } from '../../lib/gcodeToolpath';
// @ts-expect-error -- vendored build output; typed by its sibling .d.ts, which
// vitest's resolver does not pick up for a bare .js import.
import { buildSegmentData, makeToolpath, TYPE_COLOR } from '../../lib/vendor/toolpathRenderer.js';
/**
* A minimal but realistic slice: two layers, an outer wall square and some
* infill on each, with a travel between them. Written the way Bambu and Orca
* actually emit -- relative extrusion, `;TYPE:` before each run, `;WIDTH:`,
* and a bare Z move for the layer change.
*/
const GCODE = `
;TYPE:Custom
M83
G1 Z0.2 F600
;TYPE:Outer wall
;WIDTH:0.42
G1 X0 Y0 F1200
G1 X10 Y0 E0.5
G1 X10 Y10 E0.5
G1 X0 Y10 E0.5
G1 X0 Y0 E0.5
;TYPE:Sparse infill
G1 X2 Y2 F9000
G1 X8 Y8 E0.3
G1 Z0.4 F600
;TYPE:Outer wall
G1 X0 Y0 F1200
G1 X10 Y0 E0.5
G1 X10 Y10 E0.5
;TYPE:Support
G1 X20 Y20 E0.4
`;
describe('parseGcodeToolpath', () => {
const parsed = parseGcodeToolpath(GCODE);
it('splits the file into layers in print order', () => {
expect(parsed.layers.length).toBe(2);
expect(parsed.layers[0].z).toBeCloseTo(0.2);
expect(parsed.layers[1].z).toBeCloseTo(0.4);
});
it('counts extrusions and travels separately', () => {
// 4 wall + 1 infill on layer 1; 2 wall + 1 support on layer 2.
expect(parsed.segmentCount).toBe(8);
// The two repositioning moves before a run, plus the initial Z lift.
expect(parsed.travelCount).toBeGreaterThan(0);
});
it('classifies features from the ;TYPE: annotations', () => {
const types = (layer: number) => {
const p = parsed.layers[layer].paths;
const out: number[] = [];
for (let i = 0; i < p.length; i += 8) out.push(p[i + 3]);
return out;
};
expect(types(0)).toContain(ToolpathType.wall);
expect(types(0)).toContain(ToolpathType.sparseInfill);
expect(types(1)).toContain(ToolpathType.support);
});
it('marks non-extruding moves as travel', () => {
const p = parsed.layers[0].paths;
const travels: number[] = [];
for (let i = 0; i < p.length; i += 8) {
if (p[i + 3] === ToolpathType.travel) travels.push(i);
}
expect(travels.length).toBeGreaterThan(0);
});
it('reads the extrusion width out of the file rather than assuming one', () => {
expect(parsed.defaultWidth).toBeCloseTo(0.42);
});
it('handles absolute extrusion as well as relative', () => {
// Without M83, E values are cumulative; treating them as relative would
// make every move after the first look like a huge extrusion, and a
// retraction would read as an extrusion rather than a travel.
const absolute = parseGcodeToolpath(`
;TYPE:Outer wall
M82
G1 X0 Y0 Z0.2
G1 X10 Y0 E1.0
G1 X10 Y10 E2.0
G1 X0 Y10 E1.5
`);
// Two extrusions (E rising), then one retraction (E falling) as a travel.
expect(absolute.segmentCount).toBe(2);
expect(absolute.travelCount).toBeGreaterThan(0);
});
it('keeps geometry for an unrecognised feature name', () => {
// A type we do not know must never drop the move -- a hole in the preview
// is far worse than a wrongly coloured segment.
const unknown = parseGcodeToolpath(`
M83
;TYPE:Some Future Feature
G1 X0 Y0 Z0.2
G1 X5 Y5 E0.4
`);
expect(unknown.segmentCount).toBe(1);
});
it('reports the model bounds', () => {
expect(parsed.bounds).not.toBeNull();
expect(parsed.bounds!.max[0]).toBeCloseTo(20);
expect(parsed.bounds!.max[1]).toBeCloseTo(20);
});
it('returns nothing rather than throwing on a file with no moves', () => {
const empty = parseGcodeToolpath('; just a comment\nM104 S200\n');
expect(empty.layers).toEqual([]);
expect(empty.bounds).toBeNull();
});
});
describe('the vendored libvgcode renderer accepts our parse', () => {
const parsed = parseGcodeToolpath(GCODE);
it('builds segment data from the parsed layers', () => {
const data = buildSegmentData(parsed.layers, parsed.defaultWidth);
expect(data.nSeg).toBe(parsed.segmentCount);
expect(data.layerCount).toBe(2);
expect(data.hasNaN).toBe(false);
expect(data.bbox).not.toBeNull();
});
it('carries travel moves through as a separate stream', () => {
const data = buildSegmentData(parsed.layers, parsed.defaultWidth);
expect(data.nTrav).toBe(parsed.travelCount);
});
it('keeps per-vertex feature, width and layer metadata', () => {
const data = buildSegmentData(parsed.layers, parsed.defaultWidth);
expect(data.meta.vType.length).toBe(data.nV);
expect(Array.from(data.meta.vType)).toContain(ToolpathType.wall);
expect(Array.from(data.meta.vLayer)).toContain(1);
// Width came from ;WIDTH:, not the fallback.
expect(Array.from(data.meta.vWidth).some((w) => Math.abs(w - 0.42) < 1e-6)).toBe(true);
});
it('builds a three.js mesh on our own three version', () => {
// The whole reason this renderer is usable: it imports no three and takes
// the namespace as an argument, so it runs on our 0.181 rather than the
// 0.160 its own package pins.
const data = buildSegmentData(parsed.layers, parsed.defaultWidth);
const handle = makeToolpath(THREE, data);
expect(handle.mesh).toBeInstanceOf(THREE.Mesh);
expect(handle.nSeg).toBe(parsed.segmentCount);
expect(handle.layerCount).toBe(2);
const geometry = handle.mesh.geometry as THREE.BufferGeometry;
// The drawn primitive is libvgcode's diamond cross-section: 8 triangles,
// 24 indices, instanced once per segment. That single indexed draw is what
// keeps a million-segment print to one call.
expect(geometry.index?.count).toBe(24);
expect(geometry.attributes.seg_id_a_u.count).toBe(parsed.segmentCount);
expect(geometry.attributes.seg_layer_u.count).toBe(parsed.segmentCount);
handle.dispose();
});
it('exposes layer-range and travel controls', () => {
const data = buildSegmentData(parsed.layers, parsed.defaultWidth);
const handle = makeToolpath(THREE, data);
expect(() => handle.setLayerRange(0, 0)).not.toThrow();
expect(() => handle.setTravelVisible(true)).not.toThrow();
expect(handle.travLines.visible).toBe(true);
expect(() => handle.setTravelVisible(false)).not.toThrow();
expect(handle.travLines.visible).toBe(false);
handle.dispose();
});
it('ships the libvgcode feature palette', () => {
// Sanity that the vendored module is the real thing and not a stub.
expect(TYPE_COLOR[ToolpathType.wall]).toHaveLength(3);
expect(TYPE_COLOR[ToolpathType.support]).toHaveLength(3);
});
});
/**
* BambuStudio's dialect. It does not emit any of the annotations the
* OrcaSlicer/PrusaSlicer lineage uses -- no `;TYPE:`, no `;WIDTH:`, no
* `;LAYER_CHANGE` -- and reading only those rendered a real Bambu file as one
* undifferentiated colour with a layer per travel Z-hop (52 layers came out as
* 23,165). Taken from an actual sliced plate.
*/
const BAMBU_GCODE = `
M83
; CHANGE_LAYER
; Z_HEIGHT: 0.2
; LINE_WIDTH: 0.42
; FEATURE: Outer wall
G1 X10 Y10 Z0.2 F600
G1 X20 Y10 E0.5
G1 X20 Y20 E0.5
; FEATURE: Sparse infill
G1 X12 Y12 F9000
G1 X18 Y18 E0.3
; a travel Z-hop, which must not start a layer
G1 Z0.6 F600
G1 X30 Y30 F9000
G1 Z0.2 F600
; FEATURE: Support
G1 X31 Y31 E0.2
; CHANGE_LAYER
; Z_HEIGHT: 0.36
; FEATURE: Outer wall
G1 X10 Y10 Z0.36 F600
G1 X20 Y10 E0.5
`;
describe('parseGcodeToolpath — BambuStudio dialect', () => {
const parsed = parseGcodeToolpath(BAMBU_GCODE);
it('reads features from "; FEATURE:" rather than ";TYPE:"', () => {
const allTypes = parsed.layers.flatMap((layer) => {
const out: number[] = [];
for (let i = 0; i < layer.paths.length; i += 8) out.push(layer.paths[i + 3]);
return out;
});
expect(allTypes).toContain(ToolpathType.sparseInfill);
expect(allTypes).toContain(ToolpathType.support);
// Everything falling back to `wall` is the signature of the dialect bug.
expect(new Set(allTypes).size).toBeGreaterThan(2);
});
it('uses the explicit layer markers', () => {
expect(parsed.layers.length).toBe(2);
expect(parsed.layers[1].z).toBeCloseTo(0.36);
});
it('does not start a layer on a travel Z-hop', () => {
// The hop to Z0.6 and back sits inside layer one; splitting there is what
// multiplied the layer count by four hundred.
const firstLayerTypes: number[] = [];
const p = parsed.layers[0].paths;
for (let i = 0; i < p.length; i += 8) firstLayerTypes.push(p[i + 3]);
expect(firstLayerTypes).toContain(ToolpathType.support);
});
it('reads the width from "; LINE_WIDTH:"', () => {
expect(parsed.defaultWidth).toBeCloseTo(0.42);
});
it('takes the typical width, not the widest', () => {
// Widths in a real file span a 0.09 gap fill to a 1.0 purge line; the max
// made every fallback segment absurdly fat.
const mixed = parseGcodeToolpath(`
M83
; FEATURE: Outer wall
; LINE_WIDTH: 0.42
G1 X0 Y0 Z0.2
G1 X10 Y0 E0.5
G1 X10 Y10 E0.5
; LINE_WIDTH: 1.0
G1 X0 Y10 E0.5
`);
expect(mixed.defaultWidth).toBeCloseTo(0.42);
});
it('does not draw a phantom segment from the origin', () => {
// Position is unknown until the first move sets it; extruding from (0,0,0)
// drew a stray line across the bed.
const p = parsed.layers[0].paths;
let touchesOrigin = false;
for (let i = 0; i < p.length; i += 8) {
if (p[i] === 0 && p[i + 1] === 0 && p[i + 3] !== ToolpathType.travel) touchesOrigin = true;
}
expect(touchesOrigin).toBe(false);
});
});
describe('arc moves and filament tracking', () => {
// BambuStudio has arc fitting on by default. A real plate carried 706
// extruding G2/G3 moves against ~7800 linear ones, and dropping them left
// holes through curved walls and tree supports -- the "huge gaps in the
// support structure" this was reported as.
const ARC_GCODE = `
M83
; FEATURE: Outer wall
; LINE_WIDTH: 0.42
G1 X10 Y0 Z0.2 F600
G3 X0 Y10 I-10 J0 E1.0
`;
it('interpolates an arc into chords rather than dropping it', () => {
const parsed = parseGcodeToolpath(ARC_GCODE);
// A quarter circle of radius 10 at a 0.02mm chord tolerance is many
// segments; the point is that it is neither 0 nor 1.
expect(parsed.segmentCount).toBeGreaterThan(5);
});
it('keeps every interpolated chord on the arc', () => {
const parsed = parseGcodeToolpath(ARC_GCODE);
const centre = { x: 0, y: 0 };
for (const layer of parsed.layers) {
for (let i = 0; i < layer.paths.length; i += 8) {
if (layer.paths[i + 3] === ToolpathType.travel) continue;
const r = Math.hypot(layer.paths[i + 4] - centre.x, layer.paths[i + 5] - centre.y);
// Every endpoint sits on the radius, within the chord tolerance.
expect(Math.abs(r - 10)).toBeLessThan(0.1);
}
}
});
it('treats an arc with no X or Y as the helical travel lift it is', () => {
// "G3 Z0.4 I1.2 J0 P1" is BambuStudio lifting the nozzle in a spiral. It
// extrudes nothing and must not be mistaken for geometry.
const parsed = parseGcodeToolpath(`
M83
; FEATURE: Outer wall
G1 X10 Y10 Z0.2 F600
G1 X20 Y10 E0.5
G3 Z0.6 I1.217 J0 P1 F60000
`);
expect(parsed.segmentCount).toBe(1);
expect(parsed.travelCount).toBeGreaterThan(1);
});
it('does not mistake G20 or G28 for an arc', () => {
const parsed = parseGcodeToolpath(`
M83
G21
G28
; FEATURE: Outer wall
G1 X10 Y10 Z0.2
G1 X20 Y10 E0.5
`);
expect(parsed.segmentCount).toBe(1);
});
it('tracks the active filament across tool changes', () => {
const parsed = parseGcodeToolpath(`
M83
; FEATURE: Outer wall
T0
G1 X10 Y10 Z0.2 F600
G1 X20 Y10 E0.5
T1
G1 X20 Y20 E0.5
`);
const tools: number[] = [];
for (const layer of parsed.layers) {
for (let i = 0; i < layer.paths.length; i += 8) {
if (layer.paths[i + 3] !== ToolpathType.travel) tools.push(layer.paths[i + 7]);
}
}
expect(tools).toContain(0);
expect(tools).toContain(1);
});
it("ignores BambuStudio's sentinel tool numbers", () => {
// T65535 / T65279 bracket the slicer's own bookkeeping and are not
// filaments; treating them as such would key colours off a nonsense slot.
const parsed = parseGcodeToolpath(`
M83
; FEATURE: Outer wall
T0
G1 X10 Y10 Z0.2 F600
T65535
G1 X20 Y10 E0.5
`);
for (const layer of parsed.layers) {
for (let i = 0; i < layer.paths.length; i += 8) {
expect(layer.paths[i + 7]).toBeLessThanOrEqual(15);
}
}
});
it('re-keys types to filaments for the filament-coloured view', () => {
const parsed = parseGcodeToolpath(`
M83
; FEATURE: Support
T1
G1 X10 Y10 Z0.2 F600
G1 X20 Y10 E0.5
`);
const recoloured = layersByFilament(parsed.layers);
const typeOf = (layers: typeof parsed.layers) => {
for (const layer of layers) {
for (let i = 0; i < layer.paths.length; i += 8) {
if (layer.paths[i + 3] !== ToolpathType.travel) return layer.paths[i + 3];
}
}
return -1;
};
expect(typeOf(parsed.layers)).toBe(ToolpathType.support);
// Filament 1 becomes type 2 -- offset by one so slot 0 cannot collide
// with the travel index.
expect(typeOf(recoloured)).toBe(2);
// The original must be untouched: both colourings are held at once.
expect(typeOf(parsed.layers)).toBe(ToolpathType.support);
});
});
describe('hiding a feature or filament', () => {
const GCODE = `
M83
; LINE_WIDTH: 0.42
; FEATURE: Outer wall
T0
G1 X10 Y10 Z0.2 F600
G1 X20 Y10 E0.5
; FEATURE: Support
T1
G1 X30 Y10 E0.5
G1 X40 Y10 E0.5
`;
const typesOf = (layers: ReturnType<typeof parseGcodeToolpath>['layers']) => {
const out: number[] = [];
for (const layer of layers) {
for (let i = 0; i < layer.paths.length; i += 8) out.push(layer.paths[i + 3]);
}
return out;
};
it('removes the hidden feature and keeps the rest', () => {
const parsed = parseGcodeToolpath(GCODE);
const filtered = filterLayersByType(parsed.layers, new Set([ToolpathType.support]));
expect(typesOf(filtered)).not.toContain(ToolpathType.support);
expect(typesOf(filtered)).toContain(ToolpathType.wall);
});
it('keeps emptied layers so the range slider does not renumber', () => {
// Dropping a layer that the filter emptied would shift every layer above
// it, and the slider would then point at the wrong height.
const parsed = parseGcodeToolpath(GCODE);
const everything = new Set(typesOf(parsed.layers));
const filtered = filterLayersByType(parsed.layers, everything);
expect(filtered.length).toBe(parsed.layers.length);
expect(typesOf(filtered)).toEqual([]);
});
it('keeps each kept record paired with its own width', () => {
// The widths array is indexed in step with the records; dropping one
// without dropping its width would smear widths across the rest.
const parsed = parseGcodeToolpath(GCODE);
const filtered = filterLayersByType(parsed.layers, new Set([ToolpathType.travel]));
for (const layer of filtered) {
expect(layer.widths.length).toBe(layer.paths.length / 8);
}
});
it('hides by filament once the types are re-keyed', () => {
const parsed = parseGcodeToolpath(GCODE);
const byFilament = layersByFilament(parsed.layers);
// Filament 1 is keyed as type 2.
const filtered = filterLayersByType(byFilament, new Set([2]));
expect(typesOf(filtered)).not.toContain(2);
expect(typesOf(filtered)).toContain(1);
});
it('returns the input untouched when nothing is hidden', () => {
const parsed = parseGcodeToolpath(GCODE);
expect(filterLayersByType(parsed.layers, new Set())).toBe(parsed.layers);
});
});
+2 -2
View File
@@ -27,7 +27,7 @@ import { parseUTCDate } from '../utils/date';
import { Button } from './Button';
import { ConfirmModal } from './ConfirmModal';
import { ModelViewer } from './ModelViewer';
import { GcodeViewer } from './GcodeViewer';
import { GcodeToolpathViewer } from './GcodeToolpathViewer';
import type { PlateMetadata } from '../types/plates';
import { useToast } from '../contexts/ToastContext';
import { formatFileSize } from '../utils/file';
@@ -222,7 +222,7 @@ function PrinterFileViewerModal({ printerId, filePath, filename, onClose }: Prin
</div>
</div>
) : activeTab === 'gcode' && hasGcode ? (
<GcodeViewer
<GcodeToolpathViewer
gcodeUrl={api.getPrinterFileGcodeUrl(printerId, filePath)}
className="w-full h-full"
/>
@@ -0,0 +1,592 @@
/**
* G-code preview drawn the way the desktop slicer draws it.
*
* The renderer under this is OrcaSlicer's own `libvgcode`, vendored via
* `three-slicer` (see `src/lib/vendor/toolpathRenderer.js`): each extrusion is a
* diamond-section prism instanced once per segment, so a whole print is a
* single indexed draw call and the toolpath occludes itself. The previous
* viewer drew screen-space lines, which have no thickness in the scene and
* therefore cannot hide the layer behind them -- the reason a sliced model
* came out stringy and shimmering.
*
* The other half of the difference is colour. This colours by *feature* --
* wall, infill, support, bridge -- from the `;TYPE:` annotations the slicer
* writes, which is what makes a preview readable. Colouring by filament, as
* the old viewer did, paints AMS slot colours across the whole print and tells
* you nothing about what the printer is doing.
*/
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { Loader2, FileWarning } from 'lucide-react';
import { getAuthToken } from '../api/client';
import {
parseGcodeToolpath,
layersByFilament,
filterLayersByType,
ToolpathType,
type ParsedToolpath,
} from '../lib/gcodeToolpath';
// Typed by the sibling toolpathRenderer.d.ts.
import {
buildSegmentData,
makeToolpath,
computeColors,
TYPE_COLOR,
DEFAULT_RANGES_COLORS,
} from '../lib/vendor/toolpathRenderer.js';
interface GcodeToolpathViewerProps {
gcodeUrl: string;
buildVolume?: { x: number; y: number; z: number };
/**
* AMS slot colours, in tool order. When supplied the viewer opens on a
* filament-coloured view, which is what a multi-material print is usually
* being looked at for -- feature colouring answers a different question.
*/
filamentColors?: string[];
className?: string;
}
/**
* The colour modes worth offering.
*
* Upstream also exposes speed, fan and temperature, but its own implementation
* derives those from *settings* rather than the toolpath, because its slicing
* kernel doesn't expose them per segment. Reading them out of the G-code would
* give real values -- `F`, `M106` and `M104` are right there in the file -- so
* they are left out until the parser carries them, rather than shipped as
* plausible-looking guesses.
*/
const VIEW_MODES = ['filament', 'feature', 'height', 'width'] as const;
type ViewMode = (typeof VIEW_MODES)[number];
/** Feature rows for the legend, in the order the slicer lists them. */
const LEGEND_ENTRIES: Array<{ type: number; key: string; fallback: string }> = [
{ type: ToolpathType.wall, key: 'gcodeViewer.feature.wall', fallback: 'Walls' },
{ type: ToolpathType.sparseInfill, key: 'gcodeViewer.feature.sparseInfill', fallback: 'Sparse infill' },
{ type: ToolpathType.solidInfill, key: 'gcodeViewer.feature.solidInfill', fallback: 'Solid infill' },
{ type: ToolpathType.bridge, key: 'gcodeViewer.feature.bridge', fallback: 'Bridge / overhang' },
{ type: ToolpathType.support, key: 'gcodeViewer.feature.support', fallback: 'Support' },
{ type: ToolpathType.skirt, key: 'gcodeViewer.feature.skirt', fallback: 'Skirt / brim' },
{ type: ToolpathType.gapFill, key: 'gcodeViewer.feature.gapFill', fallback: 'Gap fill' },
{ type: ToolpathType.ironing, key: 'gcodeViewer.feature.ironing', fallback: 'Ironing' },
{ type: ToolpathType.primeTower, key: 'gcodeViewer.feature.primeTower', fallback: 'Prime tower' },
];
/**
* Pack a CSS hex colour the way the renderer expects.
*
* It stores colour as a single float holding `r << 16 | g << 8 | b`, which its
* shader unpacks. Matching that exactly is what lets filament colours be
* applied through the same `setColors` path the built-in views use.
*/
function packColor(hex: string): number {
const value = hex.replace('#', '');
const full = value.length === 3 ? value.split('').map((c) => c + c).join('') : value;
const n = Number.parseInt(full.slice(0, 6), 16);
return Number.isFinite(n) ? n : 0x00ae42;
}
const cssColor = (rgb: number[] | undefined): string =>
rgb ? `rgb(${rgb.map((c) => Math.round(c * 255)).join(',')})` : 'transparent';
/** The renderer's own blue-to-red ramp, as CSS gradient stops. */
function rampStops(): string {
const colors = DEFAULT_RANGES_COLORS as number[][];
return colors
.map((rgb, i) => `${cssColor(rgb)} ${((i / (colors.length - 1)) * 100).toFixed(0)}%`)
.join(', ');
}
/** Two decimals for a layer height, none for a large speed-like value. */
function formatScale(value: number): string {
if (!Number.isFinite(value)) return '-';
return Math.abs(value) < 10 ? value.toFixed(2) : value.toFixed(0);
}
export function GcodeToolpathViewer({
gcodeUrl,
buildVolume = { x: 256, y: 256, z: 256 },
filamentColors,
className = '',
}: GcodeToolpathViewerProps) {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [notSliced, setNotSliced] = useState(false);
const [parsed, setParsed] = useState<ParsedToolpath | null>(null);
const hasFilamentColors = (filamentColors?.length ?? 0) > 0;
const [viewMode, setViewMode] = useState<ViewMode>(hasFilamentColors ? 'filament' : 'feature');
// Colours are fetched, so they usually arrive after the first render and the
// initial state above lands on 'feature'. Adopt filament when they turn up,
// unless the user has already picked a mode for themselves.
const modeChosenRef = useRef(false);
useEffect(() => {
if (hasFilamentColors && !modeChosenRef.current) setViewMode('filament');
}, [hasFilamentColors]);
// Filament and feature colouring merge vertices differently, so they cannot
// share one built mesh; the toolpath is rebuilt when crossing between them.
const [layerRange, setLayerRange] = useState<[number, number]>([0, 0]);
// Hidden types, tracked separately per colour space: in filament view a
// "type" is a filament slot, in every other view it is a feature.
const [hiddenFeatures, setHiddenFeatures] = useState<ReadonlySet<number>>(new Set());
const [hiddenFilaments, setHiddenFilaments] = useState<ReadonlySet<number>>(new Set());
const filamentView = viewMode === 'filament';
const hidden = filamentView ? hiddenFilaments : hiddenFeatures;
// A stable key so the toolpath effect re-runs on a change of contents rather
// than on every new Set identity.
const hiddenKey = [...hidden].sort((a, b) => a - b).join(',');
const toggleHidden = (type: number) => {
const update = (prev: ReadonlySet<number>) => {
const next = new Set(prev);
if (next.has(type)) next.delete(type);
else next.add(type);
return next;
};
if (filamentView) setHiddenFilaments(update);
else setHiddenFeatures(update);
};
const [showTravel, setShowTravel] = useState(false);
// Kept out of state: these are three.js objects, and re-rendering React on
// every camera nudge would be pointless work.
const sceneRef = useRef<THREE.Scene | null>(null);
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
const controlsRef = useRef<OrbitControls | null>(null);
const handleRef = useRef<ReturnType<typeof makeToolpath> | null>(null);
const segmentDataRef = useRef<ReturnType<typeof buildSegmentData> | null>(null);
// Bumped when a new toolpath is built, so the colour effect re-runs against
// it -- the data it colours lives in a ref rather than in state.
const [toolpathGeneration, setToolpathGeneration] = useState(0);
// Read inside the toolpath effect without making it a dependency: a rebuild
// should honour the current controls, not reset them, and re-running on
// every slider nudge would rebuild the whole mesh.
const showTravelRef = useRef(showTravel);
const layerRangeRef = useRef(layerRange);
showTravelRef.current = showTravel;
layerRangeRef.current = layerRange;
// The camera is framed once. Re-framing on a colour-mode switch would yank
// the view back from wherever the user had put it.
const framedRef = useRef(false);
// `buildVolume` defaults to an object literal, so without this every render
// produced a new identity. That identity was a dependency of the scene
// effect, which therefore tore down and rebuilt the WebGL renderer on every
// render -- and browsers cap live WebGL contexts at around sixteen, dropping
// the oldest, which is why the canvas went blank after a few interactions.
const volumeKey = `${buildVolume.x}x${buildVolume.y}x${buildVolume.z}`;
const volume = useMemo(
() => ({ x: buildVolume.x, y: buildVolume.y, z: buildVolume.z }),
// eslint-disable-next-line react-hooks/exhaustive-deps
[volumeKey],
);
// --- Fetch and parse -----------------------------------------------------
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
setNotSliced(false);
setParsed(null);
const headers: HeadersInit = {};
const token = getAuthToken();
if (token) headers['Authorization'] = `Bearer ${token}`;
fetch(gcodeUrl, { headers })
.then(async (response) => {
if (!response.ok) {
if (response.status === 404) {
const data = await response.json().catch(() => ({}));
if (typeof data.detail === 'string' && data.detail.includes('sliced')) {
setNotSliced(true);
throw new Error('not_sliced');
}
}
throw new Error('Failed to load G-code');
}
return response.text();
})
.then((gcode) => {
if (cancelled) return;
framedRef.current = false;
const result = parseGcodeToolpath(gcode);
setParsed(result);
setLayerRange([0, Math.max(0, result.layers.length - 1)]);
setLoading(false);
})
.catch((err: Error) => {
if (cancelled) return;
if (err.message !== 'not_sliced') setError(err.message);
setLoading(false);
});
return () => {
cancelled = true;
};
}, [gcodeUrl]);
// --- Scene: created once, never rebuilt ----------------------------------
// Deliberately independent of the toolpath. Tearing the renderer down to
// recolour would leak WebGL contexts and throw away the camera the user had
// positioned.
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const width = container.clientWidth || 1;
const height = container.clientHeight || 1;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a1a);
sceneRef.current = scene;
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 10000);
cameraRef.current = camera;
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(width, height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
container.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controlsRef.current = controls;
// The bed. The toolpath shader lights itself (libvgcode carries its own
// light directions), so the scene needs no lights at all.
const grid = new THREE.GridHelper(
Math.max(volume.x, volume.y),
Math.ceil(Math.max(volume.x, volume.y) / 16),
0x444444,
0x333333,
);
// The toolpath group is rotated -90 degrees about X to take the slicer's
// Z-up space into three's Y-up, which maps (x, y, z) to (x, z, -y) -- so
// the bed's +Y runs along world -Z. Placing the grid at +Z left the print
// sitting beside its own plate rather than on it.
grid.position.set(volume.x / 2, 0, -volume.y / 2);
scene.add(grid);
let frame = 0;
const animate = () => {
frame = requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
};
animate();
const handleResize = () => {
const w = container.clientWidth || 1;
const h = container.clientHeight || 1;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
};
const observer = new ResizeObserver(handleResize);
observer.observe(container);
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
observer.disconnect();
cancelAnimationFrame(frame);
controls.dispose();
grid.geometry.dispose();
(grid.material as THREE.Material).dispose();
renderer.dispose();
container.removeChild(renderer.domElement);
sceneRef.current = null;
cameraRef.current = null;
controlsRef.current = null;
};
}, [volume]);
// --- Toolpath: rebuilt when the colouring changes its vertex layout ------
useEffect(() => {
const scene = sceneRef.current;
const camera = cameraRef.current;
const controls = controlsRef.current;
if (!scene || !camera || !controls || !parsed || parsed.layers.length === 0) return;
// Filament and feature colouring merge adjacent vertices differently, so
// they genuinely produce different vertex streams and cannot share a mesh.
const keyed = filamentView ? layersByFilament(parsed.layers) : parsed.layers;
const sourceLayers = filterLayersByType(keyed, hidden);
const data = buildSegmentData(sourceLayers, parsed.defaultWidth);
const handle = makeToolpath(THREE, data);
segmentDataRef.current = data;
handleRef.current = handle;
const group = new THREE.Group();
group.rotation.x = -Math.PI / 2;
group.add(handle.mesh);
group.add(handle.travLines);
scene.add(group);
handle.setTravelVisible(showTravelRef.current);
handle.setLayerRange(layerRangeRef.current[0], layerRangeRef.current[1]);
setToolpathGeneration((n) => n + 1);
// Frame only on first build, so switching colour mode does not yank the
// camera back from wherever the user put it.
if (!framedRef.current) {
framedRef.current = true;
// Not Box3.setFromObject: this renderer keeps segment positions in a
// data texture, and the geometry attribute is only the 8-vertex diamond
// template -- measuring the object reports a few millimetres, so the
// camera parked itself far away and the print came out tiny.
const b = parsed.bounds;
const box = b
? new THREE.Box3(
new THREE.Vector3(b.min[0], b.min[2], -b.max[1]),
new THREE.Vector3(b.max[0], b.max[2], -b.min[1]),
)
: new THREE.Box3(new THREE.Vector3(0, 0, 0), new THREE.Vector3(volume.x, 1, -volume.y));
const center = box.getCenter(new THREE.Vector3());
const radius = Math.max(box.getSize(new THREE.Vector3()).length() / 2, 0.001);
const vFov = THREE.MathUtils.degToRad(camera.fov);
const hFov = 2 * Math.atan(Math.tan(vFov / 2) * camera.aspect);
const distance = 1.15 * Math.max(radius / Math.sin(vFov / 2), radius / Math.sin(hFov / 2));
camera.position.copy(center).addScaledVector(new THREE.Vector3(0.7, 0.5, 0.7).normalize(), distance);
camera.near = Math.max(distance / 1000, 0.01);
camera.far = distance + radius * 4;
camera.updateProjectionMatrix();
controls.target.copy(center);
controls.update();
}
return () => {
scene.remove(group);
// The handle owns instanced buffers and a data texture per segment; on a
// large print that is a lot of GPU memory to leave behind.
handle.dispose();
handleRef.current = null;
segmentDataRef.current = null;
};
// hiddenKey rather than the Set, whose identity changes on every toggle.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [parsed, filamentView, volume, hiddenKey]);
// --- Controls drive the existing handle rather than rebuilding it ---------
useEffect(() => {
handleRef.current?.setLayerRange(layerRange[0], layerRange[1]);
}, [layerRange]);
useEffect(() => {
handleRef.current?.setTravelVisible(showTravel);
}, [showTravel]);
const colorResult = useMemo(() => {
const data = segmentDataRef.current;
if (!data || !parsed) return null;
if (filamentView) {
// In this mode each vertex's "type" is its filament index + 1, so the
// AMS colours can be applied straight from the per-vertex metadata.
const colors = new Float32Array(data.nV * 4);
for (let v = 0; v < data.nV; v += 1) {
const slot = Math.max(0, data.meta.vType[v] - 1);
const hex = filamentColors?.[slot] ?? filamentColors?.[0] ?? '#00ae42';
colors[v * 4] = packColor(hex);
}
return { color: colors, min: 0, max: 0, unit: '', cont: false };
}
// feature / height / width never consult the settings context, so an empty
// one is honest here; speed / fan / temp would not be, which is why they
// are not offered.
return computeColors(data, viewMode, {});
// toolpathGeneration is a dependency so a rebuilt mesh gets recoloured.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [viewMode, parsed, filamentView, filamentColors, toolpathGeneration]);
useEffect(() => {
if (colorResult) handleRef.current?.setColors(colorResult.color);
}, [colorResult]);
const layerCount = parsed?.layers.length ?? 0;
if (notSliced) {
return (
<div className={`flex flex-col items-center justify-center gap-2 text-bambu-gray ${className}`}>
<FileWarning className="w-8 h-8" />
{t('gcodeViewer.notSliced', 'This file has not been sliced yet.')}
</div>
);
}
if (error) {
return (
<div className={`flex flex-col items-center justify-center gap-2 text-bambu-gray ${className}`}>
<FileWarning className="w-8 h-8" />
{t('gcodeViewer.loadFailed', 'Could not load the G-code for this file.')}
</div>
);
}
return (
<div className={`relative ${className}`}>
<div ref={containerRef} className="w-full h-full" />
{loading && (
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-bambu-dark/60 text-sm text-bambu-gray">
<Loader2 className="w-4 h-4 animate-spin" />
{t('gcodeViewer.loading', 'Reading toolpath...')}
</div>
)}
{!loading && layerCount > 0 && (
<>
{/* Colour mode + travel toggle */}
<div className="absolute left-3 top-3 flex flex-col gap-2 rounded border border-bambu-dark-tertiary bg-bambu-dark/85 p-2">
<div className="flex overflow-hidden rounded border border-bambu-dark-tertiary">
{VIEW_MODES.filter((mode) => mode !== 'filament' || hasFilamentColors).map((mode) => (
<button
key={mode}
type="button"
onClick={() => {
modeChosenRef.current = true;
setViewMode(mode);
}}
className={`px-2 py-1 text-xs transition-colors ${
viewMode === mode ? 'bg-bambu-green text-white' : 'text-bambu-gray hover:text-white'
}`}
>
{t(`gcodeViewer.view.${mode}`, mode)}
</button>
))}
</div>
<label className="flex cursor-pointer items-center gap-2 text-xs text-bambu-gray">
<input
type="checkbox"
checked={showTravel}
onChange={(e) => setShowTravel(e.target.checked)}
className="cursor-pointer"
/>
{t('gcodeViewer.showTravel', 'Travel moves')}
</label>
{filamentView ? (
<ul className="flex flex-col gap-0.5">
{(filamentColors ?? []).map((color, slot) => {
// Filament view keys types as slot + 1; see layersByFilament.
const type = slot + 1;
const isHidden = hidden.has(type);
return (
<li key={slot}>
<button
type="button"
onClick={() => toggleHidden(type)}
aria-pressed={!isHidden}
className={`flex w-full items-center gap-1.5 text-left text-[0.7rem] transition-opacity hover:text-white ${
isHidden ? 'text-bambu-gray/40' : 'text-bambu-gray'
}`}
>
<span
className={`inline-block h-2.5 w-2.5 shrink-0 rounded-sm border border-white/20 ${isHidden ? 'opacity-25' : ''}`}
style={{ backgroundColor: color }}
aria-hidden
/>
<span className={isHidden ? 'line-through' : ''}>
{t('gcodeViewer.filamentSlot', 'Filament {{n}}', { n: slot + 1 })}
</span>
</button>
</li>
);
})}
</ul>
) : viewMode === 'feature' ? (
<ul className="flex flex-col gap-0.5">
{LEGEND_ENTRIES.map((entry) => {
const isHidden = hidden.has(entry.type);
return (
<li key={entry.type}>
<button
type="button"
onClick={() => toggleHidden(entry.type)}
aria-pressed={!isHidden}
className={`flex w-full items-center gap-1.5 text-left text-[0.7rem] transition-opacity hover:text-white ${
isHidden ? 'text-bambu-gray/40' : 'text-bambu-gray'
}`}
>
<span
className={`inline-block h-2.5 w-2.5 shrink-0 rounded-sm ${isHidden ? 'opacity-25' : ''}`}
style={{ backgroundColor: cssColor(TYPE_COLOR[entry.type]) }}
aria-hidden
/>
<span className={isHidden ? 'line-through' : ''}>{t(entry.key, entry.fallback)}</span>
</button>
</li>
);
})}
</ul>
) : (
colorResult && (
// Continuous scale. The ramp is drawn from the renderer's own
// stops rather than an approximation, and the ends are labelled
// -- a bare gradient says nothing about what the colours mean.
<div className="flex flex-col gap-1">
<div
className="h-2.5 w-full rounded-sm border border-white/10"
style={{ background: `linear-gradient(to right, ${rampStops()})` }}
aria-hidden
/>
<div className="flex items-center justify-between text-[0.7rem] tabular-nums text-bambu-gray">
<span>{formatScale(colorResult.min)}</span>
<span className="text-bambu-gray/70">{colorResult.unit}</span>
<span>{formatScale(colorResult.max)}</span>
</div>
</div>
)
)}
</div>
{/* Layer range. Two ends, because inspecting a print means isolating
a band of layers, not just capping the top. */}
<div className="absolute right-3 top-3 flex flex-col items-center gap-1 rounded border border-bambu-dark-tertiary bg-bambu-dark/85 p-2">
<span className="text-[0.65rem] tabular-nums text-bambu-gray">{layerRange[1] + 1}</span>
<input
type="range"
min={0}
max={Math.max(0, layerCount - 1)}
value={layerRange[1]}
onChange={(e) => {
const top = Number(e.target.value);
setLayerRange(([bottom]) => [Math.min(bottom, top), top]);
}}
aria-label={t('gcodeViewer.topLayer', 'Top layer')}
className="h-40 w-4 cursor-pointer"
style={{ writingMode: 'vertical-lr', direction: 'rtl' }}
/>
<input
type="range"
min={0}
max={Math.max(0, layerCount - 1)}
value={layerRange[0]}
onChange={(e) => {
const bottom = Number(e.target.value);
setLayerRange(([, top]) => [bottom, Math.max(bottom, top)]);
}}
aria-label={t('gcodeViewer.bottomLayer', 'Bottom layer')}
className="h-40 w-4 cursor-pointer"
style={{ writingMode: 'vertical-lr', direction: 'rtl' }}
/>
<span className="text-[0.65rem] tabular-nums text-bambu-gray">{layerRange[0] + 1}</span>
</div>
</>
)}
</div>
);
}
-283
View File
@@ -1,283 +0,0 @@
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { WebGLPreview } from 'gcode-preview';
import { Loader2, Layers, ChevronLeft, ChevronRight, FileWarning } from 'lucide-react';
import { getAuthToken } from '../api/client';
interface GcodeViewerProps {
gcodeUrl: string;
buildVolume?: { x: number; y: number; z: number };
filamentColors?: string[];
className?: string;
}
export function GcodeViewer({
gcodeUrl,
buildVolume = { x: 256, y: 256, z: 256 },
filamentColors,
className = ''
}: GcodeViewerProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const previewRef = useRef<WebGLPreview | null>(null);
const renderTimeoutRef = useRef<number | null>(null);
const initRef = useRef(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [notSliced, setNotSliced] = useState(false);
const [currentLayer, setCurrentLayer] = useState(0);
const [totalLayers, setTotalLayers] = useState(0);
// Memoize colors to prevent re-renders
const colorsKey = useMemo(() => JSON.stringify(filamentColors), [filamentColors]);
useEffect(() => {
if (!canvasRef.current || initRef.current) return;
initRef.current = true;
const canvas = canvasRef.current;
// Set canvas size before creating preview
const rect = canvas.parentElement?.getBoundingClientRect();
if (rect) {
canvas.width = rect.width;
canvas.height = rect.height;
}
// Use extrusionColor as array for multi-tool support
// Index in array = tool number
const hasMultiColor = filamentColors && filamentColors.length > 1;
const primaryColor = filamentColors?.[0] || '#00ae42';
// Create preview
const preview = new WebGLPreview({
canvas,
buildVolume,
backgroundColor: 0x1a1a1a,
// Pass full color array - library uses index as tool number
extrusionColor: hasMultiColor ? filamentColors : primaryColor,
disableGradient: true,
// Volumetric extrusions rather than lines. `lineWidth: 2` drew each move
// as a 2px screen-space line, which is why the surface came out stringy,
// aliased, and banded where layers overlapped -- a line has no thickness
// in the scene, so it cannot occlude the layer behind it. Tubes are built
// from the real extrusion width and height, so the print reads as a solid
// object the way it does in a desktop slicer.
renderTubes: true,
extrusionWidth: 0.42,
lineHeight: 0.2,
renderTravel: false,
renderExtrusion: true,
});
previewRef.current = preview;
// Fetch and process gcode
const headers: HeadersInit = {};
const token = getAuthToken();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
fetch(gcodeUrl, { headers })
.then(async response => {
if (!response.ok) {
if (response.status === 404) {
const data = await response.json().catch(() => ({}));
if (data.detail?.includes('sliced')) {
setNotSliced(true);
throw new Error('not_sliced');
}
}
throw new Error('Failed to load G-code');
}
return response.text();
})
.then(gcode => {
// The gcode-preview library only supports T0-T7
// We need to remap higher tool numbers to fit within this range
// First, find all unique tool numbers used
const toolNumbers = new Set<number>();
const toolRegex = /^(\s*)T(\d+)(\s*;.*)?$/gim;
let match;
while ((match = toolRegex.exec(gcode)) !== null) {
const toolNum = parseInt(match[2], 10);
if (toolNum <= 15) { // Valid tool, not a special command
toolNumbers.add(toolNum);
}
}
// Create a mapping from original tool numbers to 0-7 range
const toolMapping = new Map<number, number>();
const sortedTools = Array.from(toolNumbers).sort((a, b) => a - b);
sortedTools.forEach((tool, index) => {
toolMapping.set(tool, index % 8); // Map to 0-7
});
// Build remapped color array based on the mapping
const remappedColors: string[] = [];
sortedTools.forEach((originalTool, index) => {
const color = filamentColors?.[originalTool] || '#00ae42';
remappedColors[index % 8] = color;
});
// Process gcode: filter special commands and remap tool numbers
const cleanedGcode = gcode
.split('\n')
.map(line => {
const match = line.match(/^(\s*)T(\d+)(\s*;.*)?$/i);
if (match) {
const toolNum = parseInt(match[2], 10);
if (toolNum > 15) {
// Filter out Bambu special commands (T255, T1000, T65535, etc.)
return `; FILTERED: ${line.trim()}`;
}
// Remap tool number to 0-7 range
const mappedTool = toolMapping.get(toolNum) ?? 0;
return `${match[1]}T${mappedTool}${match[3] || ''}`;
}
return line;
})
.join('\n');
// Update colors for the preview using the remapped array
if (remappedColors.length > 0) {
(preview as unknown as { extrusionColor: string[] }).extrusionColor = remappedColors;
}
preview.processGCode(cleanedGcode);
const layers = preview.layers?.length || 0;
setTotalLayers(layers);
setCurrentLayer(layers);
preview.render();
setLoading(false);
})
.catch(err => {
if (err.message !== 'not_sliced') {
setError(err.message);
}
setLoading(false);
});
// Handle resize
const handleResize = () => {
if (canvas.parentElement && previewRef.current) {
const newRect = canvas.parentElement.getBoundingClientRect();
canvas.width = newRect.width;
canvas.height = newRect.height;
previewRef.current.resize();
}
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
if (renderTimeoutRef.current) {
cancelAnimationFrame(renderTimeoutRef.current);
}
if (previewRef.current) {
previewRef.current.dispose();
previewRef.current = null;
}
initRef.current = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gcodeUrl, colorsKey]); // Intentionally use colorsKey instead of filamentColors, buildVolume rarely changes
const handleLayerChange = useCallback((layer: number) => {
if (!previewRef.current) return;
const newLayer = Math.max(1, Math.min(layer, totalLayers));
setCurrentLayer(newLayer);
if (renderTimeoutRef.current) {
cancelAnimationFrame(renderTimeoutRef.current);
}
renderTimeoutRef.current = requestAnimationFrame(() => {
if (previewRef.current) {
previewRef.current.endLayer = newLayer;
previewRef.current.render();
}
});
}, [totalLayers]);
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
handleLayerChange(parseInt(e.target.value, 10));
};
return (
<div className={`relative flex flex-col h-full ${className}`}>
<div className="flex-1 relative bg-bambu-dark rounded-lg overflow-hidden">
<canvas ref={canvasRef} className="w-full h-full" />
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-bambu-dark/80">
<div className="text-center">
<Loader2 className="w-8 h-8 animate-spin text-bambu-green mx-auto mb-2" />
<p className="text-bambu-gray text-sm">Loading G-code...</p>
</div>
</div>
)}
{notSliced && (
<div className="absolute inset-0 flex items-center justify-center bg-bambu-dark/80">
<div className="text-center max-w-sm px-4">
<FileWarning className="w-12 h-12 text-bambu-gray mx-auto mb-3" />
<p className="text-white font-medium mb-2">G-code not available</p>
<p className="text-bambu-gray text-sm">
This file hasn't been sliced yet. G-code preview is only available
after slicing in Bambu Studio or Orca Slicer.
</p>
</div>
</div>
)}
{error && !notSliced && (
<div className="absolute inset-0 flex items-center justify-center bg-bambu-dark/80">
<div className="text-center text-red-400">
<p className="text-sm">{error}</p>
</div>
</div>
)}
</div>
{!loading && !error && !notSliced && totalLayers > 0 && (
<div className="mt-4 px-2">
<div className="flex items-center gap-3">
<Layers className="w-4 h-4 text-bambu-gray flex-shrink-0" />
<button
onClick={() => handleLayerChange(currentLayer - 1)}
disabled={currentLayer <= 1}
className="p-1 rounded hover:bg-bambu-dark-tertiary disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronLeft className="w-4 h-4" />
</button>
<input
type="range"
min={1}
max={totalLayers}
value={currentLayer}
onChange={handleSliderChange}
className="flex-1 h-2 bg-bambu-dark-tertiary rounded-lg appearance-none cursor-pointer accent-bambu-green"
/>
<button
onClick={() => handleLayerChange(currentLayer + 1)}
disabled={currentLayer >= totalLayers}
className="p-1 rounded hover:bg-bambu-dark-tertiary disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronRight className="w-4 h-4" />
</button>
<span className="text-sm text-bambu-gray min-w-[80px] text-right">
{currentLayer} / {totalLayers}
</span>
</div>
</div>
)}
</div>
);
}
+24 -6
View File
@@ -7049,12 +7049,30 @@ export default {
},
},
gcodeViewer: {
blockedTitle: '3D-Vorschau konnte nicht eingebettet werden',
blockedBody: 'Bambuddy erlaubt dieser Seite, den G-Code-Viewer eingebettet anzuzeigen, aber etwas zwischen Ihrem Browser und Bambuddy verweigert das — meist ein Reverse-Proxy oder eine Sicherheitserweiterung, die einen eigenen Frame-Header sendet. Das Öffnen des Viewers in einem eigenen Tab ist davon nicht betroffen.',
unavailableTitle: '3D-Vorschau nicht verfügbar',
unavailableBody: 'Bambuddy konnte die Dateien des G-Code-Viewers nicht ausliefern. Normalerweise fehlt dann das Verzeichnis gcode_viewer in der Installation; das Startprotokoll weist ebenfalls darauf hin.',
problemDetail: 'Meldung des Servers: {{detail}}',
openInNewTab: 'Viewer in neuem Tab öffnen',
filamentSlot: 'Filament {{n}}',
loading: 'Werkzeugweg wird gelesen…',
loadFailed: 'Der G-Code für diese Datei konnte nicht geladen werden.',
showTravel: 'Leerfahrten',
topLayer: 'Oberste Schicht',
bottomLayer: 'Unterste Schicht',
noSource: 'Es wurde keine Datei zur Vorschau angegeben.',
view: {
filament: 'Filament',
feature: 'Merkmal',
height: 'Höhe',
width: 'Breite',
},
feature: {
wall: 'Wände',
sparseInfill: 'Spärliche Füllung',
solidInfill: 'Massive Füllung',
bridge: 'Brücke / Überhang',
support: 'Stützen',
skirt: 'Skirt / Brim',
gapFill: 'Lückenfüllung',
ironing: 'Bügeln',
primeTower: 'Reinigungsturm',
},
back: 'Zurück',
backToArchives: 'Zurück zum Druckarchiv',
backToFiles: 'Zurück zum Dateimanager',
+24 -6
View File
@@ -7098,12 +7098,30 @@ export default {
},
},
gcodeViewer: {
blockedTitle: 'The 3D preview could not be embedded',
blockedBody: 'Bambuddy allows this page to show the G-code viewer inline, but something between your browser and Bambuddy is refusing it — usually a reverse proxy or a security add-on sending its own framing header. Opening the viewer in its own tab is not affected.',
unavailableTitle: 'The 3D preview is unavailable',
unavailableBody: 'Bambuddy could not serve the G-code viewer\'s files. This normally means the gcode_viewer directory is missing from the installation; the startup log says so too.',
problemDetail: 'Reported by the server: {{detail}}',
openInNewTab: 'Open the viewer in a new tab',
filamentSlot: 'Filament {{n}}',
loading: 'Reading toolpath…',
loadFailed: 'Could not load the G-code for this file.',
showTravel: 'Travel moves',
topLayer: 'Top layer',
bottomLayer: 'Bottom layer',
noSource: 'No file was given to preview.',
view: {
filament: 'Filament',
feature: 'Feature',
height: 'Height',
width: 'Width',
},
feature: {
wall: 'Walls',
sparseInfill: 'Sparse infill',
solidInfill: 'Solid infill',
bridge: 'Bridge / overhang',
support: 'Support',
skirt: 'Skirt / brim',
gapFill: 'Gap fill',
ironing: 'Ironing',
primeTower: 'Prime tower',
},
back: 'Back',
backToArchives: 'Back to Print Archives',
backToFiles: 'Back to File Manager',
+24 -6
View File
@@ -7057,12 +7057,30 @@ export default {
},
},
gcodeViewer: {
blockedTitle: 'No se pudo incrustar la vista previa 3D',
blockedBody: 'Bambuddy permite que esta página muestre el visor de G-code incrustado, pero algo entre su navegador y Bambuddy lo está rechazando — normalmente un proxy inverso o un complemento de seguridad que envía su propia cabecera de marco. Abrir el visor en su propia pestaña no se ve afectado.',
unavailableTitle: 'La vista previa 3D no está disponible',
unavailableBody: 'Bambuddy no pudo servir los archivos del visor de G-code. Esto suele significar que falta el directorio gcode_viewer en la instalación; el registro de inicio también lo indica.',
problemDetail: 'Informado por el servidor: {{detail}}',
openInNewTab: 'Abrir el visor en una pestaña nueva',
filamentSlot: 'Filamento {{n}}',
loading: 'Leyendo la trayectoria…',
loadFailed: 'No se pudo cargar el G-code de este archivo.',
showTravel: 'Movimientos en vacío',
topLayer: 'Capa superior',
bottomLayer: 'Capa inferior',
noSource: 'No se indicó ningún archivo para previsualizar.',
view: {
filament: 'Filamento',
feature: 'Elemento',
height: 'Altura',
width: 'Ancho',
},
feature: {
wall: 'Paredes',
sparseInfill: 'Relleno disperso',
solidInfill: 'Relleno sólido',
bridge: 'Puente / voladizo',
support: 'Soporte',
skirt: 'Falda / borde',
gapFill: 'Relleno de huecos',
ironing: 'Alisado',
primeTower: 'Torre de purga',
},
back: 'Atrás',
backToArchives: 'Volver a los archivos de impresión',
backToFiles: 'Volver al gestor de archivos',
+24 -6
View File
@@ -7038,12 +7038,30 @@ export default {
},
},
gcodeViewer: {
blockedTitle: 'L\'aperçu 3D n\'a pas pu être intégré',
blockedBody: 'Bambuddy autorise cette page à afficher la visionneuse G-code en ligne, mais quelque chose entre votre navigateur et Bambuddy le refuse — généralement un reverse proxy ou une extension de sécurité qui envoie son propre en-tête de cadre. L\'ouverture de la visionneuse dans un onglet dédié n\'est pas concernée.',
unavailableTitle: 'L\'aperçu 3D est indisponible',
unavailableBody: 'Bambuddy n\'a pas pu servir les fichiers de la visionneuse G-code. Cela signifie généralement que le répertoire gcode_viewer est absent de l\'installation ; le journal de démarrage l\'indique également.',
problemDetail: 'Signalé par le serveur : {{detail}}',
openInNewTab: 'Ouvrir la visionneuse dans un nouvel onglet',
filamentSlot: 'Filament {{n}}',
loading: 'Lecture de la trajectoire…',
loadFailed: 'Impossible de charger le G-code de ce fichier.',
showTravel: 'Déplacements à vide',
topLayer: 'Couche supérieure',
bottomLayer: 'Couche inférieure',
noSource: 'Aucun fichier à prévisualiser n’a été indiqué.',
view: {
filament: 'Filament',
feature: 'Élément',
height: 'Hauteur',
width: 'Largeur',
},
feature: {
wall: 'Parois',
sparseInfill: 'Remplissage clairsemé',
solidInfill: 'Remplissage plein',
bridge: 'Pont / surplomb',
support: 'Support',
skirt: 'Jupe / bordure',
gapFill: 'Remplissage des vides',
ironing: 'Lissage',
primeTower: 'Tour de purge',
},
back: 'Retour',
backToArchives: 'Retour aux archives d\'impression',
backToFiles: 'Retour au gestionnaire de fichiers',
+24 -6
View File
@@ -7037,12 +7037,30 @@ export default {
},
},
gcodeViewer: {
blockedTitle: 'Impossibile incorporare l\'anteprima 3D',
blockedBody: 'Bambuddy consente a questa pagina di mostrare il visualizzatore G-code incorporato, ma qualcosa tra il browser e Bambuddy lo rifiuta — di solito un reverse proxy o un\'estensione di sicurezza che invia una propria intestazione di frame. L\'apertura del visualizzatore in una scheda dedicata non è interessata.',
unavailableTitle: 'Anteprima 3D non disponibile',
unavailableBody: 'Bambuddy non è riuscito a servire i file del visualizzatore G-code. Di solito significa che la cartella gcode_viewer manca nell\'installazione; anche il log di avvio lo segnala.',
problemDetail: 'Segnalato dal server: {{detail}}',
openInNewTab: 'Apri il visualizzatore in una nuova scheda',
filamentSlot: 'Filamento {{n}}',
loading: 'Lettura del percorso…',
loadFailed: 'Impossibile caricare il G-code di questo file.',
showTravel: 'Spostamenti a vuoto',
topLayer: 'Strato superiore',
bottomLayer: 'Strato inferiore',
noSource: 'Nessun file da visualizzare è stato indicato.',
view: {
filament: 'Filamento',
feature: 'Elemento',
height: 'Altezza',
width: 'Larghezza',
},
feature: {
wall: 'Pareti',
sparseInfill: 'Riempimento rado',
solidInfill: 'Riempimento solido',
bridge: 'Ponte / sbalzo',
support: 'Supporto',
skirt: 'Skirt / brim',
gapFill: 'Riempimento vuoti',
ironing: 'Stiratura',
primeTower: 'Torre di spurgo',
},
back: 'Indietro',
backToArchives: 'Torna agli archivi di stampa',
backToFiles: 'Torna al gestore file',
+24 -6
View File
@@ -7049,12 +7049,30 @@ export default {
},
},
gcodeViewer: {
blockedTitle: '3Dプレビューを埋め込めませんでした',
blockedBody: 'BambuddyはこのページにG-codeビューアーを埋め込んで表示することを許可していますが、ブラウザーとBambuddyの間にある何かがそれを拒否しています。多くの場合、独自のフレームヘッダーを送信するリバースプロキシやセキュリティ拡張が原因です。ビューアーを別のタブで開く場合は影響ありません。',
unavailableTitle: '3Dプレビューを利用できません',
unavailableBody: 'BambuddyがG-codeビューアーのファイルを配信できませんでした。通常はインストールに gcode_viewer ディレクトリが存在しないことを意味します。起動ログにも記録されています。',
problemDetail: 'サーバーからの報告: {{detail}}',
openInNewTab: 'ビューアーを新しいタブで開く',
filamentSlot: 'フィラメント {{n}}',
loading: 'ツールパスを読み込んでいます…',
loadFailed: 'このファイルの G コードを読み込めませんでした。',
showTravel: '移動(非押出)',
topLayer: '上端レイヤー',
bottomLayer: '下端レイヤー',
noSource: 'プレビューするファイルが指定されていません。',
view: {
filament: 'フィラメント',
feature: '種別',
height: '高さ',
width: '幅',
},
feature: {
wall: '壁',
sparseInfill: '疎インフィル',
solidInfill: 'ソリッドインフィル',
bridge: 'ブリッジ/オーバーハング',
support: 'サポート',
skirt: 'スカート/ブリム',
gapFill: 'ギャップ充填',
ironing: 'アイロン',
primeTower: 'プライムタワー',
},
back: '戻る',
backToArchives: '印刷アーカイブに戻る',
backToFiles: 'ファイル管理に戻る',
+24 -6
View File
@@ -6505,12 +6505,30 @@ export default {
}
},
gcodeViewer: {
blockedTitle: '3D 미리보기를 삽입할 수 없습니다',
blockedBody: 'Bambuddy는 이 페이지에 G-code 뷰어를 삽입해 표시하도록 허용하지만, 브라우저와 Bambuddy 사이의 무언가가 이를 거부하고 있습니다. 대개 자체 프레임 헤더를 보내는 리버스 프록시나 보안 추가 기능이 원인입니다. 뷰어를 별도 탭에서 여는 것은 영향을 받지 않습니다.',
unavailableTitle: '3D 미리보기를 사용할 수 없습니다',
unavailableBody: 'Bambuddy가 G-code 뷰어 파일을 제공하지 못했습니다. 보통 설치본에 gcode_viewer 디렉터리가 없다는 뜻이며, 시작 로그에도 기록됩니다.',
problemDetail: '서버 보고: {{detail}}',
openInNewTab: '새 탭에서 뷰어 열기',
filamentSlot: '필라멘트 {{n}}',
loading: '툴패스를 읽는 중…',
loadFailed: '이 파일의 G코드를 불러올 수 없습니다.',
showTravel: '이동 경로',
topLayer: '최상단 레이어',
bottomLayer: '최하단 레이어',
noSource: '미리 볼 파일이 지정되지 않았습니다.',
view: {
filament: '필라멘트',
feature: '유형',
height: '높이',
width: '너비',
},
feature: {
wall: '벽',
sparseInfill: '성긴 채움',
solidInfill: '솔리드 채움',
bridge: '브리지 / 오버행',
support: '서포트',
skirt: '스커트 / 브림',
gapFill: '틈 채움',
ironing: '다림질',
primeTower: '프라임 타워',
},
back: '뒤로',
backToArchives: '인쇄 아카이브로 돌아가기',
backToFiles: '파일 관리자로 돌아가기'
+24 -6
View File
@@ -7037,12 +7037,30 @@ export default {
},
},
gcodeViewer: {
blockedTitle: 'Não foi possível incorporar a pré-visualização 3D',
blockedBody: 'O Bambuddy permite que esta página mostre o visualizador de G-code incorporado, mas algo entre o seu navegador e o Bambuddy está recusando — normalmente um proxy reverso ou um complemento de segurança que envia o próprio cabeçalho de quadro. Abrir o visualizador em uma aba própria não é afetado.',
unavailableTitle: 'A pré-visualização 3D está indisponível',
unavailableBody: 'O Bambuddy não conseguiu servir os arquivos do visualizador de G-code. Isso normalmente significa que o diretório gcode_viewer está ausente na instalação; o log de inicialização também informa isso.',
problemDetail: 'Informado pelo servidor: {{detail}}',
openInNewTab: 'Abrir o visualizador em uma nova aba',
filamentSlot: 'Filamento {{n}}',
loading: 'Lendo a trajetória…',
loadFailed: 'Não foi possível carregar o G-code deste arquivo.',
showTravel: 'Movimentos sem extrusão',
topLayer: 'Camada superior',
bottomLayer: 'Camada inferior',
noSource: 'Nenhum arquivo foi indicado para visualização.',
view: {
filament: 'Filamento',
feature: 'Elemento',
height: 'Altura',
width: 'Largura',
},
feature: {
wall: 'Paredes',
sparseInfill: 'Preenchimento esparso',
solidInfill: 'Preenchimento sólido',
bridge: 'Ponte / saliência',
support: 'Suporte',
skirt: 'Saia / aba',
gapFill: 'Preenchimento de lacunas',
ironing: 'Alisamento',
primeTower: 'Torre de purga',
},
back: 'Voltar',
backToArchives: 'Voltar para os arquivos de impressão',
backToFiles: 'Voltar para o gerenciador de arquivos',
+24 -6
View File
@@ -6674,12 +6674,30 @@ export default {
},
},
gcodeViewer: {
blockedTitle: "Не удалось встроить 3D-предпросмотр",
blockedBody: "Bambuddy разрешает этой странице показывать просмотрщик G-code встроенным, но что-то между браузером и Bambuddy это запрещает — обычно обратный прокси или расширение безопасности, отправляющее собственный заголовок фрейма. Открытие просмотрщика в отдельной вкладке не затрагивается.",
unavailableTitle: "3D-предпросмотр недоступен",
unavailableBody: "Bambuddy не смог отдать файлы просмотрщика G-code. Обычно это значит, что в установке отсутствует каталог gcode_viewer; об этом также сообщает журнал запуска.",
problemDetail: "Сообщение сервера: {{detail}}",
openInNewTab: "Открыть просмотрщик в новой вкладке",
filamentSlot: 'Филамент {{n}}',
loading: 'Чтение траектории…',
loadFailed: 'Не удалось загрузить G-код этого файла.',
showTravel: 'Холостые перемещения',
topLayer: 'Верхний слой',
bottomLayer: 'Нижний слой',
noSource: 'Файл для предпросмотра не указан.',
view: {
filament: 'Филамент',
feature: 'Тип',
height: 'Высота',
width: 'Ширина',
},
feature: {
wall: 'Стенки',
sparseInfill: 'Разреженное заполнение',
solidInfill: 'Сплошное заполнение',
bridge: 'Мост / нависание',
support: 'Поддержка',
skirt: 'Юбка / кайма',
gapFill: 'Заполнение зазоров',
ironing: 'Разглаживание',
primeTower: 'Башня очистки',
},
back: "Назад",
backToArchives: "Вернуться в архив печати",
backToFiles: "Вернуться в файловый менеджер",
+24 -6
View File
@@ -6988,12 +6988,30 @@ export default {
},
},
gcodeViewer: {
blockedTitle: '3D önizleme gömülemedi',
blockedBody: 'Bambuddy bu sayfanın G-code görüntüleyiciyi gömülü göstermesine izin veriyor, ancak tarayıcınızla Bambuddy arasındaki bir şey bunu reddediyor — genellikle kendi çerçeve başlığını gönderen bir ters proxy veya güvenlik eklentisi. Görüntüleyiciyi kendi sekmesinde açmak bundan etkilenmez.',
unavailableTitle: '3D önizleme kullanılamıyor',
unavailableBody: 'Bambuddy, G-code görüntüleyicinin dosyalarını sunamadı. Bu genellikle kurulumda gcode_viewer dizininin eksik olduğu anlamına gelir; başlangıç günlüğü de bunu belirtir.',
problemDetail: 'Sunucunun bildirdiği: {{detail}}',
openInNewTab: 'Görüntüleyiciyi yeni sekmede aç',
filamentSlot: 'Filament {{n}}',
loading: 'Takım yolu okunuyor…',
loadFailed: 'Bu dosyanın G-code’u yüklenemedi.',
showTravel: 'Boş hareketler',
topLayer: 'Üst katman',
bottomLayer: 'Alt katman',
noSource: 'Önizlenecek dosya belirtilmedi.',
view: {
filament: 'Filament',
feature: 'Öğe',
height: 'Yükseklik',
width: 'Genişlik',
},
feature: {
wall: 'Duvarlar',
sparseInfill: 'Seyrek dolgu',
solidInfill: 'Katı dolgu',
bridge: 'Köprü / çıkıntı',
support: 'Destek',
skirt: 'Etek / kenarlık',
gapFill: 'Boşluk dolgusu',
ironing: 'Ütüleme',
primeTower: 'Temizleme kulesi',
},
back: 'Geri',
backToArchives: 'Baskı Arşivlerine Dön',
backToFiles: 'Dosya Yöneticisine Dön',
+24 -6
View File
@@ -7092,12 +7092,30 @@ export default {
},
},
gcodeViewer: {
blockedTitle: "Не вдалося вбудувати 3D-перегляд",
blockedBody: "Bambuddy дозволяє цій сторінці показувати переглядач G-code вбудованим, але щось між браузером і Bambuddy це відхиляє — зазвичай зворотний проксі або розширення безпеки, яке надсилає власний заголовок фрейму. Відкриття переглядача в окремій вкладці це не зачіпає.",
unavailableTitle: "3D-перегляд недоступний",
unavailableBody: "Bambuddy не зміг віддати файли переглядача G-code. Зазвичай це означає, що в установці бракує каталогу gcode_viewer; журнал запуску також про це повідомляє.",
problemDetail: "Повідомлення сервера: {{detail}}",
openInNewTab: "Відкрити переглядач у новій вкладці",
filamentSlot: 'Філамент {{n}}',
loading: 'Читання траєкторії…',
loadFailed: 'Не вдалося завантажити G-код цього файлу.',
showTravel: 'Холості переміщення',
topLayer: 'Верхній шар',
bottomLayer: 'Нижній шар',
noSource: 'Файл для перегляду не вказано.',
view: {
filament: 'Філамент',
feature: 'Тип',
height: 'Висота',
width: 'Ширина',
},
feature: {
wall: 'Стінки',
sparseInfill: 'Розріджене заповнення',
solidInfill: 'Суцільне заповнення',
bridge: 'Міст / навис',
support: 'Підтримка',
skirt: 'Спідниця / облямівка',
gapFill: 'Заповнення проміжків',
ironing: 'Розгладжування',
primeTower: 'Вежа очищення',
},
back: "Назад",
backToArchives: "Назад до друку архівів",
backToFiles: "Назад до файлового менеджера",
+24 -6
View File
@@ -7036,12 +7036,30 @@ export default {
},
},
gcodeViewer: {
blockedTitle: '无法嵌入 3D 预览',
blockedBody: 'Bambuddy 允许此页面内嵌显示 G-code 查看器,但浏览器与 Bambuddy 之间的某个环节拒绝了它 — 通常是发送自有框架标头的反向代理或安全插件。在独立标签页中打开查看器不受影响。',
unavailableTitle: '3D 预览不可用',
unavailableBody: 'Bambuddy 无法提供 G-code 查看器的文件。这通常表示安装中缺少 gcode_viewer 目录;启动日志中也会有相应记录。',
problemDetail: '服务器报告:{{detail}}',
openInNewTab: '在新标签页中打开查看器',
filamentSlot: '耗材 {{n}}',
loading: '正在读取走刀路径…',
loadFailed: '无法加载此文件的 G-code。',
showTravel: '空驶移动',
topLayer: '顶层',
bottomLayer: '底层',
noSource: '未指定要预览的文件。',
view: {
filament: '耗材',
feature: '类型',
height: '高度',
width: '宽度',
},
feature: {
wall: '墙体',
sparseInfill: '稀疏填充',
solidInfill: '实心填充',
bridge: '桥接 / 悬垂',
support: '支撑',
skirt: '裙边 / 边缘',
gapFill: '间隙填充',
ironing: '熨烫',
primeTower: '擦料塔',
},
back: '返回',
backToArchives: '返回打印归档',
backToFiles: '返回文件管理器',
+24 -6
View File
@@ -7036,12 +7036,30 @@ export default {
},
},
gcodeViewer: {
blockedTitle: '無法嵌入 3D 預覽',
blockedBody: 'Bambuddy 允許此頁面內嵌顯示 G-code 檢視器,但瀏覽器與 Bambuddy 之間的某個環節拒絕了它 — 通常是傳送自有框架標頭的反向代理或安全外掛。在獨立分頁中開啟檢視器不受影響。',
unavailableTitle: '3D 預覽無法使用',
unavailableBody: 'Bambuddy 無法提供 G-code 檢視器的檔案。這通常表示安裝中缺少 gcode_viewer 目錄;啟動記錄中也會有相應紀錄。',
problemDetail: '伺服器回報:{{detail}}',
openInNewTab: '在新分頁中開啟檢視器',
filamentSlot: '耗材 {{n}}',
loading: '正在讀取走刀路徑…',
loadFailed: '無法載入此檔案的 G-code。',
showTravel: '空跑移動',
topLayer: '頂層',
bottomLayer: '底層',
noSource: '未指定要預覽的檔案。',
view: {
filament: '耗材',
feature: '類型',
height: '高度',
width: '寬度',
},
feature: {
wall: '牆體',
sparseInfill: '稀疏填充',
solidInfill: '實心填充',
bridge: '橋接 / 懸垂',
support: '支撐',
skirt: '裙邊 / 邊緣',
gapFill: '間隙填充',
ironing: '熨燙',
primeTower: '擦料塔',
},
back: '返回',
backToArchives: '返回列印歸檔',
backToFiles: '返回檔案管理器',
+471
View File
@@ -0,0 +1,471 @@
/**
* Parses a G-code file into the per-layer form the vendored libvgcode renderer
* consumes (`src/lib/vendor/toolpathRenderer.js`).
*
* This is the piece that does not exist upstream. `three-slicer` renders its own
* slicing kernel's output and ships no G-code parser at all, so a preview of a
* *file* -- which is all Bambuddy ever has -- needs the toolpath reconstructed
* from the text.
*
* The renderer's input is one entry per layer:
*
* { z, paths: Float32Array (stride 8), widths: number[] }
*
* where each stride-8 record is `x0, y0, z0, type, x1, y1, z1, _` and `type` is
* 0 for a travel move or a feature index otherwise. Layer height is derived by
* the renderer from the gaps between consecutive `z` values, so layers must
* arrive in print order.
*
* Deliberately hand-rolled rather than reusing `gcode-preview`'s parser: that
* one models moves for a line renderer and keeps `;TYPE:` only as an opaque
* comment string, so the feature classification below -- the thing that makes a
* preview readable -- would have to be written here anyway.
*/
/**
* Feature indices the renderer's palette is keyed on. Values are fixed by
* `TYPE_COLOR` in the vendored module, which took them from libvgcode; changing
* one silently recolours the preview.
*/
export const ToolpathType = {
travel: 0,
wall: 1,
sparseInfill: 2,
solidInfill: 3,
skirt: 4,
support: 5,
raft: 6,
gapFill: 7,
thinWall: 8,
bridge: 9,
ironing: 10,
primeTower: 11,
} as const;
/**
* `;TYPE:` values as OrcaSlicer and BambuStudio emit them, lowercased.
*
* Both spell several of these differently across versions ("Overhang wall" vs
* "Overhang perimeter"), and PrusaSlicer-lineage names turn up in third-party
* files, so the table is deliberately generous. Anything unrecognised falls
* back to `wall`, which is visually neutral -- better a mis-coloured segment
* than a missing one, since an unknown type must never drop geometry.
*/
const FEATURE_BY_COMMENT: Record<string, number> = {
'outer wall': ToolpathType.wall,
'inner wall': ToolpathType.wall,
perimeter: ToolpathType.wall,
'external perimeter': ToolpathType.wall,
'overhang wall': ToolpathType.bridge,
'overhang perimeter': ToolpathType.bridge,
'sparse infill': ToolpathType.sparseInfill,
'internal infill': ToolpathType.sparseInfill,
'solid infill': ToolpathType.solidInfill,
'internal solid infill': ToolpathType.solidInfill,
'top surface': ToolpathType.solidInfill,
'top solid infill': ToolpathType.solidInfill,
'bottom surface': ToolpathType.solidInfill,
skirt: ToolpathType.skirt,
'skirt/brim': ToolpathType.skirt,
brim: ToolpathType.skirt,
support: ToolpathType.support,
'support material': ToolpathType.support,
'support interface': ToolpathType.support,
'support material interface': ToolpathType.support,
'support transition': ToolpathType.support,
raft: ToolpathType.raft,
'gap fill': ToolpathType.gapFill,
'gap infill': ToolpathType.gapFill,
'thin wall': ToolpathType.thinWall,
// Bambu-only names.
'floating vertical shell': ToolpathType.solidInfill,
'internal bridge': ToolpathType.bridge,
'bottom shell': ToolpathType.solidInfill,
bridge: ToolpathType.bridge,
'bridge infill': ToolpathType.bridge,
'internal bridge infill': ToolpathType.bridge,
ironing: ToolpathType.ironing,
'prime tower': ToolpathType.primeTower,
'wipe tower': ToolpathType.primeTower,
custom: ToolpathType.wall,
};
/** One layer in the shape `buildSegmentData` expects. */
export interface ToolpathLayer {
z: number;
paths: Float32Array;
widths: number[];
}
export interface ParsedToolpath {
layers: ToolpathLayer[];
/** Extruding segments, excluding travels. */
segmentCount: number;
travelCount: number;
/** Nozzle/line width seen in the file, for the renderer's fallback. */
defaultWidth: number;
bounds: { min: [number, number, number]; max: [number, number, number] } | null;
}
const RECORD_STRIDE = 8;
const TAU = Math.PI * 2;
/** Chord flatness for arc interpolation, in mm. Below an extrusion width. */
const ARC_TOLERANCE_MM = 0.02;
/** Ceiling on chords per arc, so a huge radius cannot blow up the buffer. */
const ARC_MAX_CHORDS = 256;
/** Tool numbers above this are slicer sentinels, not filaments. */
const MAX_TOOL = 15;
/** Growable stride-8 record buffer; typed arrays cannot be pushed to. */
class PathBuffer {
private data = new Float32Array(1024 * RECORD_STRIDE);
private count = 0;
readonly widths: number[] = [];
push(
x0: number, y0: number, z0: number,
type: number,
x1: number, y1: number, z1: number,
width: number,
tool: number,
): void {
if ((this.count + 1) * RECORD_STRIDE > this.data.length) {
const grown = new Float32Array(this.data.length * 2);
grown.set(this.data);
this.data = grown;
}
const o = this.count * RECORD_STRIDE;
this.data[o] = x0;
this.data[o + 1] = y0;
this.data[o + 2] = z0;
this.data[o + 3] = type;
this.data[o + 4] = x1;
this.data[o + 5] = y1;
this.data[o + 6] = z1;
// Slot 7 is unread by the renderer, so the active filament rides along in
// it. That is what lets the viewer offer a filament-coloured view without
// parsing the file twice: it swaps slot 3 for slot 7 and rebuilds.
this.data[o + 7] = tool;
this.count += 1;
this.widths.push(width);
}
get length(): number {
return this.count;
}
/** Trimmed copy — the renderer walks the whole array, so slack would render. */
toFloat32Array(): Float32Array {
return this.data.slice(0, this.count * RECORD_STRIDE);
}
}
/** Most frequently seen key, or undefined when the tally is empty. */
function modeOf(tally: Map<number, number>): number | undefined {
let best: number | undefined;
let bestCount = 0;
for (const [value, count] of tally) {
if (count > bestCount) {
best = value;
bestCount = count;
}
}
return best;
}
/** Reads a named axis out of a `G0`/`G1` line without allocating per token. */
function readAxis(line: string, axis: string): number | undefined {
const at = line.indexOf(axis);
if (at < 0) return undefined;
// Guard against matching inside a comment or a word ("; X marks").
const value = Number.parseFloat(line.slice(at + 1));
return Number.isFinite(value) ? value : undefined;
}
/**
* Parse G-code into per-layer toolpath records.
*
* Relative extrusion (`M83`) and absolute (`M82`) are both handled, because
* Bambu writes relative and plenty of third-party files do not. Anything the
* parser cannot make sense of is skipped rather than guessed at.
*/
export function parseGcodeToolpath(gcode: string): ParsedToolpath {
const layers: ToolpathLayer[] = [];
let x = 0;
let y = 0;
let z = 0;
let e = 0;
let relativeExtrusion = false;
let feature: number = ToolpathType.wall;
let width = 0;
// Tally of observed widths. The *typical* one is wanted, not the largest: a
// file's widths range from a 0.09 gap fill to a 1.0 purge line, and taking
// the max made the fallback wildly too fat.
const widthTally = new Map<number, number>();
let segmentCount = 0;
let travelCount = 0;
let current = new PathBuffer();
let currentZ = 0;
// A file with explicit layer markers is trusted; without them, layers are
// inferred from the Z at which material is *laid down*.
let sawLayerMarker = false;
let pendingZ: number | null = null;
// Travels share the layer buffer, so "the buffer is empty" is not the same
// question as "this layer has laid anything down yet" -- and it is the first
// *extrusion* that fixes a layer's height.
let layerHasExtrusion = false;
// Active filament. BambuStudio also emits sentinel tool numbers
// (T65535 / T65279) around its own bookkeeping; those are not filaments.
let tool = 0;
// Suppresses a phantom segment from the origin: the machine's position is
// unknown until the first move sets it, and drawing from (0,0,0) put a stray
// line across the bed.
let hasPosition = false;
let minX = Infinity, minY = Infinity, minZ = Infinity;
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
const flushLayer = () => {
if (current.length === 0) return;
layers.push({ z: currentZ, paths: current.toFloat32Array(), widths: current.widths });
current = new PathBuffer();
layerHasExtrusion = false;
if (pendingZ !== null) {
currentZ = pendingZ;
pendingZ = null;
}
};
/**
* Record one straight run from the current position, updating bounds. Shared
* by linear moves and by each chord an arc is flattened into.
*/
const emit = (nx: number, ny: number, nz: number, extruding: boolean) => {
if (extruding) {
if (!sawLayerMarker && layerHasExtrusion && nz !== currentZ) flushLayer();
if (!layerHasExtrusion) {
currentZ = nz;
pendingZ = null;
}
layerHasExtrusion = true;
if (!hasPosition) {
hasPosition = true;
x = nx; y = ny; z = nz;
return;
}
current.push(x, y, z, feature, nx, ny, nz, width, tool);
segmentCount += 1;
if (nx < minX) minX = nx;
if (ny < minY) minY = ny;
if (nz < minZ) minZ = nz;
if (nx > maxX) maxX = nx;
if (ny > maxY) maxY = ny;
if (nz > maxZ) maxZ = nz;
} else if (hasPosition) {
current.push(x, y, z, ToolpathType.travel, nx, ny, nz, 0, tool);
travelCount += 1;
}
x = nx; y = ny; z = nz;
hasPosition = true;
};
for (const rawLine of gcode.split('\n')) {
const line = rawLine.trim();
if (line.length === 0) continue;
if (line.charCodeAt(0) === 59 /* ; */) {
// Slicer annotations, in either dialect. BambuStudio writes
// "; FEATURE: Outer wall" and "; CHANGE_LAYER"; OrcaSlicer and the
// PrusaSlicer lineage write ";TYPE:Outer wall" and ";LAYER_CHANGE".
// Reading only one of them is why an earlier version of this parser
// rendered a Bambu file as a single undifferentiated colour.
const body = line.slice(1).trimStart();
const colon = body.indexOf(':');
const key = (colon >= 0 ? body.slice(0, colon) : body).trim().toUpperCase();
const value = colon >= 0 ? body.slice(colon + 1).trim() : '';
if (key === 'FEATURE' || key === 'TYPE') {
feature = FEATURE_BY_COMMENT[value.toLowerCase()] ?? ToolpathType.wall;
} else if (key === 'LINE_WIDTH' || key === 'WIDTH') {
const parsed = Number.parseFloat(value);
if (Number.isFinite(parsed) && parsed > 0) {
width = parsed;
widthTally.set(parsed, (widthTally.get(parsed) ?? 0) + 1);
}
} else if (key === 'CHANGE_LAYER' || key === 'LAYER_CHANGE') {
// An explicit marker is authoritative: it is the only thing that
// distinguishes a real layer change from a travel Z-hop.
sawLayerMarker = true;
flushLayer();
} else if (key === 'Z_HEIGHT' || key === 'Z') {
const parsed = Number.parseFloat(value);
if (Number.isFinite(parsed)) pendingZ = parsed;
}
continue;
}
if (line.startsWith('M83')) {
relativeExtrusion = true;
continue;
}
if (line.startsWith('M82')) {
relativeExtrusion = false;
continue;
}
if (line.startsWith('G92')) {
const resetE = readAxis(line, 'E');
if (resetE !== undefined) e = resetE;
continue;
}
if (line.charCodeAt(0) === 84 /* T */) {
// Filament change. Values above the sensible tool range are BambuStudio
// sentinels around its own bookkeeping (T65535 / T65279), not filaments.
const picked = Number.parseInt(line.slice(1), 10);
if (Number.isFinite(picked) && picked >= 0 && picked <= MAX_TOOL) tool = picked;
continue;
}
const isArc = line.startsWith('G2 ') || line.startsWith('G3 ') || line.startsWith('G2') || line.startsWith('G3');
const isLinear = line.startsWith('G1') || line.startsWith('G0');
if (!isLinear && !isArc) continue;
// G20/G21/G28 etc. share the G-prefix; only the four move codes above are
// handled, and `startsWith('G2')` would otherwise swallow G20/G28.
if (isArc && !/^G[23](\s|$)/.test(line)) continue;
if (isLinear && !/^G[01](\s|$)/.test(line)) continue;
const nx = readAxis(line, 'X') ?? x;
const ny = readAxis(line, 'Y') ?? y;
const nz = readAxis(line, 'Z') ?? z;
const rawE = readAxis(line, 'E');
let extruded = 0;
if (rawE !== undefined) {
extruded = relativeExtrusion ? rawE : rawE - e;
e = rawE;
}
const extruding = extruded > 0;
if (isArc) {
// Arc move in the XY plane (every file seen uses G17, and I/J rather
// than R). Ignoring these dropped 706 extruding moves out of ~8500 in a
// single plate -- concentrated on curved walls and tree supports, which
// is precisely where the preview came out full of holes.
const i = readAxis(line, 'I') ?? 0;
const j = readAxis(line, 'J') ?? 0;
const cx = x + i;
const cy = y + j;
const radius = Math.hypot(i, j);
if (radius > 0) {
const startAngle = Math.atan2(y - cy, x - cx);
const endAngle = Math.atan2(ny - cy, nx - cx);
const clockwise = line.charCodeAt(1) === 50; /* G2 */
let sweep = endAngle - startAngle;
if (clockwise) {
while (sweep >= 0) sweep -= TAU;
while (sweep < -TAU) sweep += TAU;
} else {
while (sweep <= 0) sweep += TAU;
while (sweep > TAU) sweep -= TAU;
}
// A move with no X/Y is a full turn -- BambuStudio's helical travel
// lift -- and `P` says how many.
if (nx === x && ny === y) {
const turns = Math.max(1, Math.round(readAxis(line, 'P') ?? 1));
sweep = (clockwise ? -TAU : TAU) * turns;
}
// Chord count from a flatness tolerance rather than a fixed step, so a
// 40mm arc is not drawn with the same four chords as a 1mm one.
const maxStep = 2 * Math.acos(Math.max(-1, Math.min(1, 1 - ARC_TOLERANCE_MM / radius)));
const steps = Math.max(1, Math.min(ARC_MAX_CHORDS, Math.ceil(Math.abs(sweep) / Math.max(maxStep, 1e-3))));
for (let step = 1; step <= steps; step += 1) {
const fraction = step / steps;
const angle = startAngle + sweep * fraction;
emit(
cx + radius * Math.cos(angle),
cy + radius * Math.sin(angle),
z + (nz - z) * fraction,
extruding,
);
}
continue;
}
// Degenerate arc (no radius): fall through and treat it as a straight
// move rather than dropping the geometry.
}
if (nx !== x || ny !== y || nz !== z) emit(nx, ny, nz, extruding);
}
flushLayer();
return {
layers,
segmentCount,
travelCount,
defaultWidth: modeOf(widthTally) ?? 0.42,
bounds: segmentCount > 0 ? { min: [minX, minY, minZ], max: [maxX, maxY, maxZ] } : null,
};
}
/**
* Re-key a parsed toolpath so each record's *type* is its filament rather than
* its feature, for a filament-coloured view.
*
* Cheaper than parsing twice, and it has to be a copy rather than an in-place
* edit: the renderer merges adjacent vertices only when their type matches, so
* the two colourings genuinely produce different vertex streams and cannot
* share one built mesh.
*
* Travels keep type 0 so they stay travels.
*/
export function layersByFilament(layers: ToolpathLayer[]): ToolpathLayer[] {
return layers.map((layer) => {
const paths = layer.paths.slice();
for (let i = 0; i < paths.length; i += RECORD_STRIDE) {
if (paths[i + 3] !== ToolpathType.travel) {
// +1 so filament 0 does not collide with the travel index.
paths[i + 3] = Math.min(paths[i + 7] + 1, MAX_TOOL);
}
}
return { ...layer, paths };
});
}
/**
* Drop records whose type is hidden, so they never reach the renderer.
*
* Hiding has to happen here rather than by recolouring: the shader packs
* colour into a single float with no alpha channel, so there is no
* "transparent" to set. Removing the records is also what makes hiding
* useful -- a hidden support genuinely stops occluding the model behind it.
*/
export function filterLayersByType(layers: ToolpathLayer[], hidden: ReadonlySet<number>): ToolpathLayer[] {
if (hidden.size === 0) return layers;
const out: ToolpathLayer[] = [];
for (const layer of layers) {
const kept = new PathBuffer();
for (let i = 0; i < layer.paths.length; i += RECORD_STRIDE) {
const type = layer.paths[i + 3];
if (hidden.has(type)) continue;
kept.push(
layer.paths[i], layer.paths[i + 1], layer.paths[i + 2], type,
layer.paths[i + 4], layer.paths[i + 5], layer.paths[i + 6],
layer.widths[i / RECORD_STRIDE] ?? 0,
layer.paths[i + 7],
);
}
// A layer emptied by the filter is still a layer: dropping it would
// renumber every layer above it and make the range slider lie.
out.push({ ...layer, paths: kept.toFloat32Array(), widths: kept.widths });
}
return out;
}
+106
View File
@@ -0,0 +1,106 @@
// Types for ./toolpathRenderer.js -- see that file for provenance and licence.
// three-slicer/viewer/toolpath — 커널 레이어 → GPU 인스턴싱 툴패스.
// three 를 import 하지 않는다: makeToolpath 가 THREE 네임스페이스를 인자로 받는다(단일 인스턴스 보장).
/** 8정점 다이아몬드 단면의 24 삼각형 인덱스 (SegmentTemplate.cpp:18 원본) */
export const VERTEX_DATA: number[]
/** 툴패스 타입 → 색. 0=travel, 1=wall … 11=prime */
export const TYPE_COLOR: Record<number, number[]>
export const TYPE_LABEL: Record<number, string>
/** 파랑→빨강 11색 히트맵 (libvgcode ColorRange.hpp:14) */
export const DEFAULT_RANGES_COLORS: number[][]
export interface ViewTypeDef {
key: 'feature' | 'speed' | 'height' | 'width' | 'fan' | 'temp'
label: string
/** 연속값(히트맵)인가. false 면 고정색. */
cont: boolean
unit: string
}
export const VIEW_TYPES: ViewTypeDef[]
/** per-vertex 부가 정보 */
export interface SegmentMeta {
vType: Uint8Array
vWidth: Float32Array
vHeight: Float32Array
vLayer: Int32Array
}
export interface SegmentData {
position: Float32Array
hwa: Float32Array
segIndex: Uint32Array
nV: number
nSeg: number
layerSegPrefix: Uint32Array
travelPos: Float32Array
travelPrefix: Uint32Array
nTrav: number
layerCount: number
maxAbs: number
hasNaN: boolean
meta: SegmentMeta
/** 타입별 총 압출 길이 (index = 툴패스 타입, 길이 16) */
typeLengths: Float64Array
/** 정점이 하나도 없으면 null */
bbox: { min: [number, number, number]; max: [number, number, number] } | null
}
/** 커널 layers[{z, paths(stride8), widths[]}] → GPU 스트림. */
export function buildSegmentData(layers: unknown[], defaultLineWidth: number): SegmentData
/** 타입별 길이 비율(%), pct 내림차순. 시간은 커널이 role 별로 노출하지 않아 길이로 근사한다. */
export function roleRatios(typeLengths: Float64Array | number[]): Array<{
type: number
label: string
pct: number
color: number[]
}>
/**
* speed/fan/temp 는 커널 툴패스에 없어 설정값에서 유도한다 — 그 유도에 쓰는 값들.
*/
export interface ColorContext {
speedByType?: Record<number, number>
firstLayerSpeed?: number
fanByType?: Record<number, number>
fanFirstLayers?: number
tempNormal?: number
tempFirst?: number
closeFanLayers?: number
}
export interface ColorResult {
/** per-vertex RGBA (nV*4). `.r` 에 색이 packed 돼 있다. */
color: Float32Array
min: number
max: number
viewType: ViewTypeDef['key']
label: string
unit: string
/** false 면 고정색 — min/max 는 0 이고 범례를 그리면 안 된다. */
cont: boolean
}
export function computeColors(data: SegmentData, viewType: ViewTypeDef['key'], ctx: ColorContext): ColorResult
export interface ToolpathHandle {
/** THREE.Mesh — 인자로 넘긴 THREE 네임스페이스의 타입 */
mesh: any
/** THREE.LineSegments (travel) */
travLines: any
setLayerRange(lo: number, hi: number): void
/** 하위호환 — setLayerRange(0, n-1) 과 같다. */
setVisibleLayers(n: number): void
setTravelVisible(visible: boolean): void
setColors(color: Float32Array): void
dispose(): void
nSeg: number
layerCount: number
}
/** @param THREE - `import * as THREE from 'three'` 네임스페이스. 소비자 인스턴스를 그대로 쓴다. */
export function makeToolpath(THREE: any, data: SegmentData): ToolpathHandle
+406
View File
@@ -0,0 +1,406 @@
/*
* GPU-instanced volumetric toolpath renderer.
*
* Vendored verbatim from `three-slicer@0.1.1` (`three-slicer/viewer/toolpath`,
* i.e. viewer/dist/toolpath_gpu.js), which is itself a port of OrcaSlicer's
* `libvgcode` -- the renderer the desktop slicer draws its own G-code preview
* with. The diamond segment cross-section, the feature palette and the
* blue-to-red range ramp all come from there, which is why output built on this
* matches Studio rather than approximating it.
*
* upstream: https://github.com/kimgh06/Web_Three_Slicer
* licence: AGPL-3.0-or-later (same as Bambuddy)
*
* Vendored rather than depended upon: the npm package carries an 8 MB WASM
* slicing kernel and pins `three@^0.160`, neither of which we want. This module
* imports nothing -- `makeToolpath` takes the THREE namespace as an argument --
* so it runs against our own three.js version.
*
* Do not edit. To update, re-copy from a newer three-slicer release.
*/
const le = [
0,
1,
2,
0,
2,
3,
// front spike
0,
3,
4,
0,
4,
5,
// right/bottom body
0,
5,
6,
0,
6,
1,
// left/top body
5,
4,
7,
5,
7,
6
// back spike
], U = {
0: [0.42, 0.45, 0.5],
1: [0.85, 0.51, 0.17],
2: [0.21, 0.45, 0.76],
3: [0.35, 0.75, 0.85],
4: [0.16, 0.68, 0.4],
5: [0.66, 0.42, 0.85],
6: [0.55, 0.45, 0.35],
7: [0.95, 0.85, 0.25],
8: [0.9, 0.35, 0.65],
9: [0.9, 0.25, 0.25],
10: [0.6, 0.82, 0.55],
11: [0.3, 0.72, 0.7]
};
function ie(t) {
const i = Math.round(t[0] * 255), s = Math.round(t[1] * 255), n = Math.round(t[2] * 255);
return i << 16 | s << 8 | n;
}
function ue(t, i) {
const s = t.length, n = i > 0 ? i : 0.42, l = new Array(s);
for (let e = 0; e < s; e++) {
const o = t[e].z;
l[e] = Math.max(0.02, e === 0 ? o : o - t[e - 1].z);
}
const a = [], h = [], _ = [], y = [], v = [], r = [], x = [], b = [], A = [], M = [], L = new Float64Array(16), P = [], k = [];
let W = -1, $ = 0, R = 0, H = 0, E = -1, c = 0;
const d = 1e-4, u = (e, o, g, m, p, w) => (a.push(e), h.push(o), _.push(g), r.push(m), y.push(p), v.push(w), x.push(c), b.push(!1), a.length - 1);
for (let e = 0; e < s; e++) {
const o = t[e].paths, g = t[e].widths, m = l[e];
if (c = e, !!o)
for (let p = 0; p < o.length; p += 8) {
const w = o[p + 3], F = o[p], I = o[p + 1], G = o[p + 2], S = o[p + 4], V = o[p + 5], B = o[p + 6];
if (w === 0) {
P.push(F, I, G, S, V, B), k.push(e);
continue;
}
const O = g && g[p / 8] > 0 ? g[p / 8] : n;
w < 16 && (L[w] += Math.hypot(S - F, V - I));
let D;
W >= 0 && E === w && Math.abs($ - F) < d && Math.abs(R - I) < d && Math.abs(H - G) < d ? (D = W, u(S, V, B, w, m, O)) : (D = u(F, I, G, w, m, O), u(S, V, B, w, m, O)), b[D] = !0, W = D + 1, $ = S, R = V, H = B, E = w, A.push(D), M.push(e);
}
}
const f = a.length, C = A.length, z = new Float32Array(f * 4), Y = new Float32Array(f * 4);
let ee = 0, ne = !1, j = 1 / 0, T = 1 / 0, X = 1 / 0, Z = -1 / 0, q = -1 / 0, J = -1 / 0;
for (let e = 0; e < f; e++) {
const o = y[e], g = a[e], m = h[e], p = _[e] - 0.5 * o;
z[e * 4] = g, z[e * 4 + 1] = m, z[e * 4 + 2] = p;
const w = e > 0 && b[e - 1], F = b[e];
let I = 0;
if (w || F) {
const G = w ? a[e] - a[e - 1] : 0, S = w ? h[e] - h[e - 1] : 0, V = w ? _[e] - _[e - 1] : 0, B = F ? a[e + 1] - a[e] : 0, O = F ? h[e + 1] - h[e] : 0, D = F ? _[e + 1] - _[e] : 0;
I = Math.atan2(G * O - S * B, G * B + S * O + V * D);
}
Y[e * 4] = o, Y[e * 4 + 1] = v[e], Y[e * 4 + 2] = I, Y[e * 4 + 3] = ie(U[r[e]] || U[1]), (!Number.isFinite(g) || !Number.isFinite(m) || !Number.isFinite(p) || !Number.isFinite(I)) && (ne = !0), ee = Math.max(ee, Math.abs(g), Math.abs(m), Math.abs(p)), g < j && (j = g), g > Z && (Z = g), m < T && (T = m), m > q && (q = m), p < X && (X = p), p > J && (J = p);
}
const te = new Uint32Array(C * 4);
for (let e = 0; e < C; e++)
te[e * 4] = A[e], te[e * 4 + 1] = M[e];
const K = { vType: new Uint8Array(f), vWidth: new Float32Array(f), vHeight: new Float32Array(f), vLayer: new Int32Array(f) };
for (let e = 0; e < f; e++)
K.vType[e] = r[e], K.vWidth[e] = v[e], K.vHeight[e] = y[e], K.vLayer[e] = x[e];
const oe = new Int32Array(s + 1);
{
let e = 0;
for (let o = 0; o < s; o++) {
for (; e < C && M[e] === o; ) e++;
oe[o + 1] = e;
}
}
const Q = k.length, N = new Float32Array(Q * 6);
for (let e = 0; e < N.length; e++) N[e] = P[e];
for (let e = 0; e < N.length; e += 3) {
const o = N[e], g = N[e + 1], m = N[e + 2];
o < j && (j = o), o > Z && (Z = o), g < T && (T = g), g > q && (q = g), m < X && (X = m), m > J && (J = m);
}
const re = new Int32Array(s + 1);
{
let e = 0;
for (let o = 0; o < s; o++) {
for (; e < Q && k[e] === o; ) e++;
re[o + 1] = e;
}
}
const se = f + Q > 0 ? { min: [j, T, X], max: [Z, q, J] } : null;
return { position: z, hwa: Y, segIndex: te, nV: f, nSeg: C, layerSegPrefix: oe, travelPos: N, travelPrefix: re, nTrav: Q, layerCount: s, maxAbs: ee, hasNaN: ne, meta: K, typeLengths: L, bbox: se };
}
const _e = { 1: "벽", 2: "스파스", 3: "솔리드", 4: "스커트", 5: "서포트", 6: "래프트", 7: "갭필", 8: "씬월", 9: "브리지", 10: "아이어닝", 11: "프라임" };
function ve(t) {
let i = 0;
for (let n = 1; n < 16; n++) i += t[n] || 0;
const s = [];
if (i <= 0) return s;
for (let n = 1; n < 16; n++) {
const l = t[n] || 0;
l > 0 && s.push({ type: n, label: _e[n] || "t" + n, pct: 100 * l / i, color: U[n] || U[1] });
}
return s.sort((n, l) => l.pct - n.pct);
}
const ce = [
[11, 44, 122],
[19, 89, 133],
[28, 136, 145],
[4, 214, 15],
[170, 242, 0],
[252, 249, 3],
[245, 206, 10],
[227, 136, 32],
[209, 104, 48],
[194, 82, 60],
[148, 38, 22]
].map((t) => [t[0] / 255, t[1] / 255, t[2] / 255]);
function he(t, i, s, n) {
const l = n.length;
if (!(s > i)) return n[0];
const a = (s - i) / (l - 1), h = (t - i) / a, _ = Math.max(0, Math.min(l - 1, Math.floor(h))), y = Math.max(0, Math.min(l - 1, _ + 1)), v = h - _, r = n[_], x = n[y];
return [r[0] + (x[0] - r[0]) * v, r[1] + (x[1] - r[1]) * v, r[2] + (x[2] - r[2]) * v];
}
const ae = [
{ key: "feature", label: "Feature type", cont: !1, unit: "" },
{ key: "speed", label: "Speed", cont: !0, unit: "mm/s" },
{ key: "height", label: "Layer Height", cont: !0, unit: "mm" },
{ key: "width", label: "Line Width", cont: !0, unit: "mm" },
{ key: "fan", label: "Fan Speed", cont: !0, unit: "%" },
{ key: "temp", label: "Temperature", cont: !0, unit: "°C" }
];
function de(t, i, s, n) {
const l = i.vType[s], a = i.vLayer[s], h = a === 0;
switch (t) {
case "height":
return i.vHeight[s];
case "width":
return i.vWidth[s];
case "speed":
return h ? n.firstLayerSpeed : n.speedByType[l] ?? n.speedByType[1];
case "fan":
return a < n.closeFanLayers ? 0 : l === 9 ? 100 : n.fanNormal;
case "temp":
return h ? n.tempFirst : n.tempNormal;
default:
return 0;
}
}
function ge(t, i, s) {
const { meta: n, nV: l } = t, a = new Float32Array(l * 4), h = ae.find((r) => r.key === i) || ae[0];
if (!h.cont) {
for (let r = 0; r < l; r++) a[r * 4] = ie(U[n.vType[r]] || U[1]);
return { color: a, min: 0, max: 0, viewType: i, label: h.label, unit: h.unit, cont: !1 };
}
let _ = 1 / 0, y = -1 / 0;
const v = new Float32Array(l);
for (let r = 0; r < l; r++) {
const x = de(i, n, r, s);
v[r] = x, x < _ && (_ = x), x > y && (y = x);
}
Number.isFinite(_) || (_ = 0, y = 1);
for (let r = 0; r < l; r++) {
const x = he(v[r], _, y, ce);
a[r * 4] = ie(x);
}
return { color: a, min: _, max: y, viewType: i, label: h.label, unit: h.unit, cont: !0 };
}
const fe = `
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp usampler2D;
#define POINTY_CAPS
#define FIX_TWISTING
const vec3 light_top_dir = vec3(-0.4574957, 0.4574957, 0.7624929);
const float light_top_diffuse = 0.6 * 0.8;
const float light_top_specular = 0.6 * 0.125;
const float light_top_shininess = 20.0;
const vec3 light_front_dir = vec3(0.6985074, 0.1397015, 0.6985074);
const float light_front_diffuse = 0.6 * 0.3;
const float ambient = 0.3;
const float emission = 0.15;
const vec3 UP = vec3(0, 0, 1);
uniform mat4 view_matrix;
uniform mat4 projection_matrix;
uniform vec3 camera_position;
uniform sampler2D position_tex;
uniform sampler2D height_width_angle_tex;
uniform int layer_lo; // 25단계: 이중 슬라이더 하한(레이어). 범위 밖 세그먼트는 셰이더가 O(1) 클립.
uniform int layer_hi; // 상한은 instanceCount 로 컷(레이어 순 정렬).
in float vertex_id_float;
in uint seg_id_a_u; // 인스턴스 어트리뷰트(구 segment_index_tex.r) — 어트리뷰트 fetch 가 texelFetch 보다 쌈
in uint seg_layer_u; // 인스턴스 어트리뷰트(구 segment_index_tex.g)
out vec3 color;
vec3 decode_color(float col) {
int c = int(round(col));
int r = (c >> 16) & 0xFF;
int g = (c >> 8) & 0xFF;
int b = (c >> 0) & 0xFF;
float f = 1.0 / 255.0;
return f * vec3(r, g, b);
}
float lighting(vec3 eye_position, vec3 eye_normal) {
float top_diffuse = light_top_diffuse * max(dot(eye_normal, light_top_dir), 0.0);
float front_diffuse = light_front_diffuse * max(dot(eye_normal, light_front_dir), 0.0);
float top_specular = light_top_specular * pow(max(dot(-normalize(eye_position), reflect(-light_top_dir, eye_normal)), 0.0), light_top_shininess);
return ambient + top_diffuse + front_diffuse + top_specular + emission;
}
ivec2 tex_coord(sampler2D sampler, int id) {
ivec2 tex_size = textureSize(sampler, 0);
return (tex_size.y == 1) ? ivec2(id, 0) : ivec2(id % tex_size.x, id / tex_size.x);
}
void main() {
int vertex_id = int(vertex_id_float);
int seg_layer = int(seg_layer_u);
if (seg_layer < layer_lo || seg_layer > layer_hi) { gl_Position = vec4(2.0, 2.0, 2.0, 1.0); return; } // 범위 밖 → 클립
int id_a = int(seg_id_a_u);
int id_b = id_a + 1;
vec3 pos_a = texelFetch(position_tex, tex_coord(position_tex, id_a), 0).xyz;
vec3 pos_b = texelFetch(position_tex, tex_coord(position_tex, id_b), 0).xyz;
vec3 line = pos_b - pos_a;
float line_len = length(line);
vec3 line_dir;
if (line_len < 1e-4)
line_dir = vec3(1.0, 0.0, 0.0);
else
line_dir = line / line_len;
vec3 line_right_dir;
if (abs(dot(line_dir, UP)) > 0.9) {
line_right_dir = normalize(cross(vec3(1, 0, 0), line_dir));
}
else
line_right_dir = normalize(cross(line_dir, UP));
vec3 line_up_dir = normalize(cross(line_right_dir, line_dir));
const vec2 horizontal_vertical_view_signs_array[16] = vec2[](
vec2(1.0, 0.0), vec2(0.0, 1.0), vec2(0.0, 0.0), vec2(0.0, -1.0),
vec2(0.0, -1.0), vec2(1.0, 0.0), vec2(0.0, 1.0), vec2(0.0, 0.0),
vec2(0.0, 1.0), vec2(-1.0, 0.0), vec2(0.0, 0.0), vec2(1.0, 0.0),
vec2(1.0, 0.0), vec2(0.0, 1.0), vec2(-1.0, 0.0), vec2(0.0, 0.0)
);
int id = vertex_id < 4 ? id_a : id_b;
vec3 endpoint_pos = vertex_id < 4 ? pos_a : pos_b;
vec4 hwa_color = texelFetch(height_width_angle_tex, tex_coord(height_width_angle_tex, id), 0); // .xyz=h/w/angle, .w=packed color
vec3 height_width_angle = hwa_color.xyz;
#ifdef FIX_TWISTING
int closer_id = (dot(camera_position - pos_a, camera_position - pos_a) < dot(camera_position - pos_b, camera_position - pos_b)) ? id_a : id_b;
vec3 closer_pos = (closer_id == id_a) ? pos_a : pos_b;
vec3 camera_view_dir = normalize(closer_pos - camera_position);
vec3 closer_height_width_angle = texelFetch(height_width_angle_tex, tex_coord(height_width_angle_tex, closer_id), 0).xyz;
vec3 diagonal_dir_border = normalize(closer_height_width_angle.x * line_up_dir + closer_height_width_angle.y * line_right_dir);
#else
vec3 camera_view_dir = normalize(endpoint_pos - camera_position);
vec3 diagonal_dir_border = normalize(height_width_angle.x * line_up_dir + height_width_angle.y * line_right_dir);
#endif
bool is_vertical_view = abs(dot(camera_view_dir, line_up_dir)) / abs(dot(diagonal_dir_border, line_up_dir)) >
abs(dot(camera_view_dir, line_right_dir)) / abs(dot(diagonal_dir_border, line_right_dir));
vec2 signs = horizontal_vertical_view_signs_array[vertex_id + 8 * int(is_vertical_view)];
#ifndef POINTY_CAPS
if (vertex_id == 2 || vertex_id == 7) signs = -horizontal_vertical_view_signs_array[(vertex_id - 2) + 8 * int(is_vertical_view)];
#endif
float view_right_sign = sign(dot(-camera_view_dir, line_right_dir));
float view_top_sign = sign(dot(-camera_view_dir, line_up_dir));
float half_height = 0.5 * height_width_angle.x;
float half_width = 0.5 * height_width_angle.y;
vec3 horizontal_dir = half_width * line_right_dir;
vec3 vertical_dir = half_height * line_up_dir;
float horizontal_sign = signs.x * view_right_sign;
float vertical_sign = signs.y * view_top_sign;
vec3 pos = endpoint_pos + horizontal_sign * horizontal_dir + vertical_sign * vertical_dir;
if (vertex_id == 2 || vertex_id == 7) {
float line_dir_sign = (vertex_id == 2) ? -1.0 : 1.0;
if (height_width_angle.z == 0.0) {
#ifdef POINTY_CAPS
pos += line_dir_sign * line_dir * half_width;
#endif
}
else {
pos += line_dir_sign * line_dir * half_width * sin(abs(height_width_angle.z) * 0.5);
pos += sign(height_width_angle.z) * horizontal_dir * cos(abs(height_width_angle.z) * 0.5);
}
}
vec3 eye_position = (view_matrix * vec4(pos, 1.0)).xyz;
vec3 eye_normal = (view_matrix * vec4(normalize(pos - endpoint_pos), 0.0)).xyz;
vec3 color_base = decode_color(hwa_color.w);
color = color_base * lighting(eye_position, eye_normal);
gl_Position = projection_matrix * vec4(eye_position, 1.0);
}
`, pe = `
precision highp float;
in vec3 color;
out vec4 fragment_color;
void main() {
fragment_color = vec4(color, 1.0);
}
`;
function me(t, i) {
const s = (c, d) => {
const u = Math.min(2048, Math.max(1, d)), f = Math.max(1, Math.ceil(d / u)), C = new Float32Array(u * f * 4);
C.set(c.subarray(0, Math.min(c.length, u * f * 4)));
const z = new t.DataTexture(C, u, f, t.RGBAFormat, t.FloatType);
return z.minFilter = z.magFilter = t.NearestFilter, z.generateMipmaps = !1, z.needsUpdate = !0, z;
}, n = s(i.position, i.nV), l = s(i.hwa, i.nV), a = new t.InstancedBufferGeometry();
a.setIndex(le), a.setAttribute("position", new t.BufferAttribute(new Float32Array(8 * 3), 3)), a.setAttribute("vertex_id_float", new t.BufferAttribute(new Float32Array([0, 1, 2, 3, 4, 5, 6, 7]), 1));
const h = new t.InstancedInterleavedBuffer(i.segIndex, 4);
a.setAttribute("seg_id_a_u", new t.InterleavedBufferAttribute(h, 1, 0)), a.setAttribute("seg_layer_u", new t.InterleavedBufferAttribute(h, 1, 1)), a.instanceCount = 0;
const _ = new t.RawShaderMaterial({
glslVersion: t.GLSL3,
uniforms: {
view_matrix: { value: new t.Matrix4() },
projection_matrix: { value: new t.Matrix4() },
camera_position: { value: new t.Vector3() },
position_tex: { value: n },
height_width_angle_tex: { value: l },
layer_lo: { value: 0 },
layer_hi: { value: i.layerCount }
},
vertexShader: fe,
fragmentShader: pe,
side: t.DoubleSide
}), y = new t.Mesh(a, _);
let v = null;
if (i.bbox) {
const { min: c, max: d } = i.bbox, u = new t.Vector3((c[0] + d[0]) / 2, (c[1] + d[1]) / 2, (c[2] + d[2]) / 2), f = Math.hypot(d[0] - c[0], d[1] - c[1], d[2] - c[2]) / 2 + 2;
v = new t.Sphere(u, f);
}
y.frustumCulled = !!v, v && (a.boundingSphere = v);
const r = new t.Matrix4(), x = new t.Vector3();
y.onBeforeRender = (c, d, u) => {
_.uniforms.projection_matrix.value.copy(u.projectionMatrix), _.uniforms.view_matrix.value.multiplyMatrices(u.matrixWorldInverse, y.matrixWorld), r.copy(y.matrixWorld).invert(), u.getWorldPosition(x).applyMatrix4(r), _.uniforms.camera_position.value.copy(x);
};
const b = new t.BufferGeometry();
b.setAttribute("position", new t.BufferAttribute(i.travelPos, 3)), b.setDrawRange(0, 0);
const A = new t.LineSegments(b, new t.LineBasicMaterial({ color: 7041658 }));
A.frustumCulled = !!v, A.visible = !1, v && (b.boundingSphere = v);
let M = !1, L = 0, P = i.layerCount - 1;
const k = () => {
const c = i.travelPrefix[L], d = i.travelPrefix[P + 1];
b.setDrawRange(M ? c * 2 : 0, M ? (d - c) * 2 : 0);
}, W = (c, d) => {
const u = i.layerCount;
L = Math.max(0, Math.min(u - 1, c | 0)), P = Math.max(L, Math.min(u - 1, d | 0)), _.uniforms.layer_lo.value = L, _.uniforms.layer_hi.value = P, a.instanceCount = i.layerSegPrefix[P + 1], k();
};
return { mesh: y, travLines: A, setVisibleLayers: (c) => W(0, (c | 0) - 1), setLayerRange: W, setTravelVisible: (c) => {
M = !!c, A.visible = M, k();
}, setColors: (c) => {
const d = l.image.data, u = Math.min(c.length, d.length) / 4;
for (let f = 0; f < u; f++) d[f * 4 + 3] = c[f * 4];
l.needsUpdate = !0;
}, dispose: () => {
a.dispose(), _.dispose(), n.dispose(), l.dispose(), b.dispose(), A.material.dispose();
}, nSeg: i.nSeg, layerCount: i.layerCount };
}
export {
ce as DEFAULT_RANGES_COLORS,
U as TYPE_COLOR,
_e as TYPE_LABEL,
le as VERTEX_DATA,
ae as VIEW_TYPES,
ue as buildSegmentData,
ge as computeColors,
me as makeToolpath,
ve as roleRatios
};
+86 -118
View File
@@ -1,95 +1,95 @@
import { useEffect, useState } from 'react';
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { ArrowLeft, ExternalLink, ShieldAlert } from 'lucide-react';
import { ArrowLeft } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { findFramingRefusal, type FrameProblem } from '../utils/framing';
import { api } from '../api/client';
import { GcodeToolpathViewer } from '../components/GcodeToolpathViewer';
/**
* Full-page G-code preview.
*
* Previously an iframe onto a vendored copy of PrettyGCode served from
* `/gcode-viewer/`. That brought its own problems -- a second viewer to keep
* packaged and updated, no way to theme or translate it, and a whole
* frame-refusal probe to detect when a proxy blocked the embed -- and its
* output was the thing this page exists to show.
*
* It now renders Bambuddy's own toolpath viewer, which draws with OrcaSlicer's
* `libvgcode` and colours by feature. Same component as the file-manager
* preview, so the two surfaces cannot drift apart.
*/
export function GCodeViewerPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { t } = useTranslation();
const [problem, setProblem] = useState<FrameProblem | null>(null);
// Forward the outer page's query string (e.g. ?archive=82) to the iframe so
// the adapter inside can pick up the archive to load. The iframe itself must
// keep the trailing slash on /gcode-viewer/ so it hits the raw-viewer route;
// the outer SPA URL uses no trailing slash so a reload falls through to the
// SPA catch-all and keeps the Bambuddy layout shell.
const iframeSrc = `/gcode-viewer/${window.location.search}`;
const embedded = window !== window.top;
const archiveId = searchParams.get('archive');
const libraryFileId = searchParams.get('library_file');
const plate = searchParams.get('plate');
// A frame refused by X-Frame-Options / frame-ancestors still fires `onLoad` —
// the browser commits its own "refused to connect" error page — so the iframe
// itself cannot tell us anything. Ask for the same URL directly instead: it is
// same-origin, so every response header is readable, and it travels through
// whatever proxy the browser reaches Bambuddy by. The iframe is rendered
// straight away regardless and only replaced if this comes back refusing,
// which keeps the working case exactly as fast as before.
useEffect(() => {
if (embedded) return;
const controller = new AbortController();
(async () => {
try {
const response = await fetch(iframeSrc, {
credentials: 'same-origin',
signal: controller.signal,
});
if (!response.ok) {
setProblem({ kind: 'unavailable', detail: `HTTP ${response.status}` });
return;
}
const refusal = findFramingRefusal(
response.headers.get('x-frame-options'),
response.headers.get('content-security-policy'),
window.location.origin,
);
if (refusal) setProblem({ kind: 'blocked', detail: refusal });
} catch {
// Aborted, offline, or the probe itself was blocked. The iframe stays;
// guessing at a cause we have no evidence for would be worse than the
// browser's own error page.
}
})();
return () => controller.abort();
}, [iframeSrc, embedded]);
// Filament colours, so a multi-material print opens on its own colours.
// The two sources differ: an archive reports them through its capabilities,
// while a library file carries them in its plate metadata, read straight out
// of the 3MF's slice info. Neither is worth blocking the preview over -- the
// viewer falls back to feature colouring -- hence no retry and no error path.
const archiveColorsQuery = useQuery({
queryKey: ['gcode-viewer-archive-colors', archiveId],
queryFn: () => api.getArchiveCapabilities(Number(archiveId)),
enabled: Boolean(archiveId),
staleTime: 5 * 60_000,
retry: false,
});
// Safety guard: if this React app is itself inside an iframe (e.g. the
// StaticFiles mount isn't registered and serve_spa returned us here),
// don't render another iframe — that would create an infinite loop.
if (embedded) {
return (
<div style={{ padding: 32, color: '#f88' }}>
GCode viewer static files not found. Check that the{' '}
<code>gcode_viewer/</code> directory exists and restart uvicorn.
</div>
);
}
const libraryPlatesQuery = useQuery({
queryKey: ['gcode-viewer-library-colors', libraryFileId],
queryFn: () => api.getLibraryFilePlates(Number(libraryFileId)),
enabled: Boolean(libraryFileId),
staleTime: 5 * 60_000,
retry: false,
});
const cameFromArchive = searchParams.has('archive');
const cameFromLibrary = searchParams.has('library_file');
const fallbackPath = cameFromArchive ? '/archives' : cameFromLibrary ? '/files' : '/';
const backLabel = cameFromArchive
? t('gcodeViewer.backToArchives')
: cameFromLibrary
? t('gcodeViewer.backToFiles')
: t('gcodeViewer.back');
const filamentColors = useMemo<string[] | undefined>(() => {
if (archiveId) return archiveColorsQuery.data?.filament_colors;
const plates = libraryPlatesQuery.data?.plates ?? [];
// Colours are per plate; use the one being previewed.
const wanted = plate ? Number(plate) : null;
const source = (wanted != null && plates.find((p) => p.index === wanted)) || plates[0];
if (!source?.filaments?.length) return undefined;
// slot_id is 1-based and the G-code's tool numbers are 0-based, so index
// by slot - 1 or every colour lands one filament out.
const colors: string[] = [];
for (const filament of source.filaments) {
const slot = Math.max(0, (filament.slot_id ?? 1) - 1);
if (filament.color) colors[slot] = filament.color;
}
return colors.length > 0 ? colors : undefined;
}, [archiveId, archiveColorsQuery.data, libraryPlatesQuery.data, plate]);
const gcodeUrl = useMemo(() => {
// Multi-plate sources need the plate carried through, or the viewer shows
// whichever plate the backend defaults to rather than the one picked.
const withPlate = (base: string) => (plate ? `${base}?plate=${encodeURIComponent(plate)}` : base);
if (archiveId) return withPlate(api.getArchiveGcode(Number(archiveId)));
if (libraryFileId) return withPlate(api.getLibraryFileGcodeUrl(Number(libraryFileId)));
return null;
}, [archiveId, libraryFileId, plate]);
const handleBack = () => {
// Prefer browser history so we land where the user actually was (preserving
// scroll position, filters, etc.). Fall back to a sensible default route
// when the viewer was opened from a fresh tab / shared link.
if (window.history.length > 1) {
navigate(-1);
} else {
navigate(fallbackPath);
}
if (window.history.length > 1) navigate(-1);
else navigate(archiveId ? '/archives' : '/files');
};
const backLabel = archiveId
? t('gcodeViewer.backToArchives', 'Back to Archives')
: t('gcodeViewer.backToFiles', 'Back to File Manager');
return (
// h-14 (3.5 rem) is the fixed header height defined in Layout.tsx.
// Subtracting it prevents a double scrollbar inside the layout shell.
<div style={{ height: 'calc(100vh - 3.5rem)', display: 'flex', flexDirection: 'column' }}>
<div className="flex items-center gap-2 px-4 py-2 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
<div className="flex flex-col h-full">
<div className="flex-shrink-0 px-4 py-2 border-b border-bambu-dark-tertiary">
<button
type="button"
onClick={handleBack}
@@ -99,49 +99,17 @@ export function GCodeViewerPage() {
{backLabel}
</button>
</div>
{problem ? (
<div className="flex-1 overflow-y-auto p-6">
<div role="alert" className="max-w-2xl mx-auto p-4 rounded-lg border border-amber-500/40 bg-amber-500/10">
<div className="flex items-start gap-3">
<ShieldAlert className="w-5 h-5 text-amber-400 shrink-0 mt-0.5" />
<div className="min-w-0">
<p className="text-sm font-medium text-amber-300">
{problem.kind === 'blocked'
? t('gcodeViewer.blockedTitle')
: t('gcodeViewer.unavailableTitle')}
</p>
<p className="text-xs text-bambu-gray mt-1">
{problem.kind === 'blocked'
? t('gcodeViewer.blockedBody')
: t('gcodeViewer.unavailableBody')}
</p>
<p className="text-xs text-bambu-gray mt-2 font-mono break-all">
{t('gcodeViewer.problemDetail', { detail: problem.detail })}
</p>
<a
href={iframeSrc}
target="_blank"
rel="noreferrer"
className="mt-3 inline-flex items-center gap-1 text-xs text-bambu-green hover:underline"
>
<ExternalLink className="w-3 h-3" />
{t('gcodeViewer.openInNewTab')}
</a>
</div>
</div>
</div>
</div>
) : (
<iframe
src={iframeSrc}
title="GCode Viewer"
style={{
display: 'block',
width: '100%',
flex: 1,
border: 'none',
}}
{gcodeUrl ? (
<GcodeToolpathViewer
gcodeUrl={gcodeUrl}
filamentColors={filamentColors}
className="flex-1 min-h-0"
/>
) : (
<div className="flex-1 flex items-center justify-center text-sm text-bambu-gray">
{t('gcodeViewer.noSource', 'No file was given to preview.')}
</div>
)}
</div>
);
-65
View File
@@ -1,65 +0,0 @@
/**
* Reading a response's framing headers, for the embedded G-code viewer (#2787).
*
* The viewer is the only part of Bambuddy that embeds a Bambuddy page in a
* frame, so it is the only part a proxy-added framing header can break — and it
* breaks with the browser's own error page, which says nothing about what was
* refused or by whom.
*/
/** Why the viewer could not be shown inline, with the evidence that says so. */
export type FrameProblem =
| { kind: 'blocked'; detail: string }
| { kind: 'unavailable'; detail: string };
/**
* Decide whether a response's framing headers allow `origin` to embed it.
*
* Returns the offending header verbatim when embedding is refused, or null when
* it is allowed. Bambuddy's own headers always allow it (`frame-ancestors
* 'self'` plus `X-Frame-Options: SAMEORIGIN`, set in `main.py`), so a refusal
* means something between the browser and Bambuddy — a reverse proxy, a
* security add-on, an auth gateway — added a stricter one.
*
* `frame-ancestors` wins outright when present: per CSP the browser must ignore
* `X-Frame-Options` entirely in that case, so reading both would blame a
* proxy-added `X-Frame-Options: DENY` the browser never consulted. Multiple CSP
* headers are *intersected*, and `fetch` joins them into one comma-separated
* string, so every `frame-ancestors` occurrence has to permit us — not just the
* first one.
*/
export function findFramingRefusal(
xFrameOptions: string | null,
contentSecurityPolicy: string | null,
origin: string,
): string | null {
const csp = contentSecurityPolicy ?? '';
const directives = [...csp.matchAll(/(?:^|[;,])\s*frame-ancestors\s+([^;,]*)/gi)];
if (directives.length > 0) {
const self = origin.toLowerCase();
for (const [, raw] of directives) {
const value = raw.trim();
const sources = value.toLowerCase().split(/\s+/).filter(Boolean);
const permitsUs = sources.some(
(source) =>
source === '*' ||
source === "'self'" ||
source === self ||
source === self.replace(/^https?:\/\//, ''),
);
if (!permitsUs) return `Content-Security-Policy: frame-ancestors ${value}`;
}
return null;
}
// No frame-ancestors anywhere: the legacy header governs. Anything other than
// a single SAMEORIGIN refuses us — DENY, ALLOW-FROM, or the conflicting
// "SAMEORIGIN, DENY" that appears when a proxy appends a second copy.
const legacy = (xFrameOptions ?? '')
.split(',')
.map((value) => value.trim().toLowerCase())
.filter(Boolean);
if (legacy.length === 0) return null;
if (legacy.length === 1 && legacy[0] === 'sameorigin') return null;
return `X-Frame-Options: ${xFrameOptions}`;
}
+1 -67
View File
@@ -1,77 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
import fs from 'fs'
import type { Connect } from 'vite'
// Backend port for dev server proxy (default: 8000)
const backendPort = process.env.BACKEND_PORT || '8000'
const backendUrl = `http://localhost:${backendPort}`
// Absolute path to the gcode_viewer directory at the repo root
const gcodeViewerDir = path.resolve(__dirname, '../gcode_viewer')
// MIME types for static files served from gcode_viewer/
const MIME: Record<string, string> = {
'.html': 'text/html; charset=utf-8',
'.js': 'application/javascript',
'.css': 'text/css',
'.obj': 'model/obj',
'.mtl': 'model/mtl',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.svg': 'image/svg+xml',
'.json': 'application/json',
'.woff': 'font/woff',
'.woff2':'font/woff2',
}
/**
* Vite dev-server plugin: serves ../gcode_viewer/ at /gcode-viewer/
* without needing a proxy to uvicorn. In production uvicorn handles it
* via the StaticFiles mount in main.py.
*/
function serveGcodeViewer() {
return {
name: 'serve-gcode-viewer',
configureServer(server: { middlewares: Connect.Server }) {
server.middlewares.use((req, res, next) => {
const url = req.url ?? ''
if (!url.startsWith('/gcode-viewer')) return next()
// Strip prefix, default to index.html
let rel = url.slice('/gcode-viewer'.length)
if (rel === '' || rel === '/') rel = '/index.html'
// Strip query string
rel = rel.split('?')[0]
const absPath = path.join(gcodeViewerDir, rel)
try {
const stat = fs.statSync(absPath)
if (stat.isFile()) {
const ext = path.extname(absPath).toLowerCase()
res.setHeader('Content-Type', MIME[ext] ?? 'application/octet-stream')
res.end(fs.readFileSync(absPath))
return
}
} catch {
// file not found — fall through to index.html
}
// SPA fallback: serve index.html for any unmatched /gcode-viewer/* path
const index = path.join(gcodeViewerDir, 'index.html')
if (fs.existsSync(index)) {
res.setHeader('Content-Type', 'text/html; charset=utf-8')
res.end(fs.readFileSync(index))
return
}
next()
})
},
}
}
export default defineConfig({
// Default base ('/') emits absolute asset URLs (/assets/...). Required so
@@ -83,7 +17,7 @@ export default defineConfig({
// fix for subpath reverse proxies (#1195, wontfix) is reverted — that
// audience uses NPM + Cloudflare Tunnel at a real domain per the
// documented workaround, which doesn't depend on this setting.
plugins: [react(), serveGcodeViewer()],
plugins: [react()],
build: {
outDir: '../static',
emptyOutDir: true,
-60
View File
@@ -1,60 +0,0 @@
# Third-Party Notices — gcode_viewer
The `gcode_viewer/` directory bundles the following third-party libraries.
All licenses are compatible with Bambuddy's AGPL-3.0.
---
## PrettyGCode (OctoPrint plugin)
- **File:** `js/prettygcode.js`
- **Source:** https://github.com/Kragrathea/OctoPrint-PrettyGCode
- **License:** AGPLv3
---
## three.js
- **Files:** `js/three.min.js`, `js/OBJLoader.js`, `js/Line2.js`,
`js/LineGeometry.js`, `js/LineMaterial.js`, `js/LineSegments2.js`,
`js/LineSegmentsGeometry.js`, `js/Lut.js`
- **Version:** r108
- **Source:** https://github.com/mrdoob/three.js
- **License:** MIT — https://github.com/mrdoob/three.js/blob/dev/LICENSE
- **Note:** `OBJLoader`, `Line2`, `LineGeometry`, `LineMaterial`,
`LineSegments2`, `LineSegmentsGeometry`, and `Lut` are examples/extras
from three.js r108, same MIT licence.
---
## jQuery
- **File:** `js/jquery.min.js`
- **Version:** v3.7.1
- **Source:** https://github.com/jquery/jquery
- **License:** MIT — https://github.com/jquery/jquery/blob/main/LICENSE.txt
---
## dat.GUI
- **File:** `js/dat.gui.js`
- **Source:** https://github.com/dataarts/dat.gui
- **License:** Apache 2.0 — https://github.com/dataarts/dat.gui/blob/master/LICENSE
---
## camera-controls
- **File:** `js/camera-controls.js`
- **Source:** https://github.com/yomotsu/camera-controls
- **License:** MIT — https://github.com/yomotsu/camera-controls/blob/main/LICENSE
---
## Helvetiker Bold (typeface.js font)
- **File:** `js/helvetiker_bold.typeface.json`
- **Source:** Bundled with three.js examples; derived from M+ FONTS
- **License:** M+ Font License (free for any use including commercial)
https://mplus-fonts.osdn.jp/about-en.html
-417
View File
@@ -1,417 +0,0 @@
/* Dark mode color variables */
:root {
--pg-bg-light: #ffffff;
--pg-bg-dark: #1e1e1e;
--pg-text-light: #000000;
--pg-text-dark: #e0e0e0;
--pg-panel-light: rgba(255, 255, 255, 0.9);
--pg-panel-dark: rgba(30, 30, 30, 0.95);
--pg-border-light: #ddd;
--pg-border-dark: #444;
--pg-input-light: #e9e9e9;
--pg-input-dark: #2a2a2a;
}
#tab_plugin_prettygcode .gwin {
width: 100%;
height: 100%;
position: relative;
}
#tab_plugin_prettygcode .webcam_rotated {
transform: rotateZ(-90deg);
}
.pgfullscreen #tab_plugin_prettygcode .gwin {
top: 0px;
left: 0px;
width: 100%;
height: 100%;
position: absolute;
}
#tab_plugin_prettygcode .fstoggle {
top: 20px;
right: 60px;
position: absolute;
z-index: 10;
}
#tab_plugin_prettygcode .pgsettingstoggle {
top: 20px;
right: 95px;
position: absolute;
z-index: 10;
}
.pgfullscreen #tab_plugin_prettygcode .pgstatetoggle {
top: 20px;
left: 20px;
position: absolute;
z-index: 10;
display: unset;
}
#tab_plugin_prettygcode .pgstatetoggle {
display: none;
}
.pgfullscreen {
color: black;
}
.pgfullscreen #tab_plugin_prettygcode .pgfilestoggle {
top: 20px;
left: 95px;
position: absolute;
z-index: 10;
display: unset;
}
#tab_plugin_prettygcode .pgfilestoggle {
display: none;
}
.pgfullscreen #tab_plugin_prettygcode .pgcameratoggle {
bottom: 20px;
right: 40px;
position: absolute;
z-index: 10;
display: unset;
}
#tab_plugin_prettygcode .pgcameratoggle {
display: none;
}
.pgfullscreen .pgstatus {
display: unset;
}
.pgstatus {
display: none;
position: absolute;
top: 0px;
left: 0px;
width: 100%;
font-size: large;
text-align: center;
background: #ffffff50;
}
.pgfullscreen #state_wrapper {
top: 20px;
left: 20px;
width: 300px;
position: absolute;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
background: rgba(255, 255, 255, 0.2);
z-index: 5;
}
.pgfullscreen #files_wrapper {
left: 95px;
top: 20px;
max-width: 80%;
width: 300px;
position: absolute;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.70);
background: rgba(204, 204, 204, 0.9);
z-index: 6;
}
/* .pgfullscreen #files .gcode_files .entry {
padding: 5px;
line-height: 20px;
border-bottom: 1px solid #ddd;
position: relative;
width: 220px;
display: inline-grid;
background: white;
height: 100px;
} */
/* .pgfullscreen #files.collapse {
width:0px;
} */
.gwin #webcam_rotator {
bottom: 20px;
right: 40px;
position: absolute;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
z-index: 5;
display: none;
}
.pgfullscreen .gwin #webcam_rotator {
display: unset;
}
.pgfullscreen .gwin #webcam_rotator.pghidden {
display: none;
}
/* Hide notifications in fullscreen mode.*/
/* todo. only do this if not logged in or as admin.
.pgfullscreen .ui-notify {
display:none;
}
*/
.pgfullscreen .pghidden {
display: none;
}
.gwin .pghidden {
display: none;
}
/*dat gui*/
#tab_plugin_prettygcode #mygui {
position: absolute;
right: 95px;
top: 20px;
opacity: 1.0;
z-index: 5;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
z-index: 4;
background: rgba(255, 255, 255, 1.0);
opacity: 0.8;
}
.gwin .dg li.save-row {
background: black;
color: yellow;
}
.gwin .dg li.save-row .button {
background: black;
color: yellow;
}
.gwin .dg li.save-row select {
height: 100%;
}
.gwin .dg .close-button {
display: none;
}
.gwin .dg li.save-row .button.revert {
display: none;
}
.gwin .dg li.save-row .button.gears {
display: none;
}
.gwin .has-save .save-row::before {
content: "Preset: ";
}
.gwin .dg li.save-row {
display: none;
}
.gwin .dg.main.taller-than-window .close-button {
border-top: 1px solid #ddd;
}
.gwin .dg.main .close-button {
background-color: #ccc;
}
.gwin .dg.main .close-button:hover {
background-color: #ddd;
}
.gwin .dg {
color: #555;
text-shadow: none !important;
}
.gwin .dg.main::-webkit-scrollbar {
background: #fafafa;
}
.gwin .dg.main::-webkit-scrollbar-thumb {
background: #bbb;
}
.gwin .dg li:not(.folder) {
background: #fafafa;
border-bottom: 1px solid #ddd;
}
.gwin .dg li.save-row .button {
text-shadow: none !important;
}
.gwin .dg li.title {
background: #e8e8e8 url(data:image/gif;base64,R0lGODlhBQAFAJEAAP////Pz8////////yH5BAEAAAIALAAAAAAFAAUAAAIIlI+hKgFxoCgAOw==) 6px 10px no-repeat;
}
.gwin .dg .cr.function:hover,
.dg .cr.boolean:hover {
background: #fff;
}
.gwin .dg .c input[type=text] {
background: #e9e9e9;
}
.gwin .dg .c input[type=text]:hover {
background: #eee;
}
.gwin .dg .c input[type=text]:focus {
background: #eee;
color: #555;
}
.gwin .dg .c .slider {
background: #e9e9e9;
}
.gwin .dg .c .slider:hover {
background: #eee;
}
/*style slider to be more progress bar like*/
.gwin #myslider .slider-track {
background: green;
}
.gwin #myslider .slider-handle {
width: 24px;
}
.gwin #myslider .tooltip {
display: none;
}
#tab_plugin_prettygcode code {
border: 1px black;
white-space: pre;
}
/*Support Octoprint Dashboard plugin*/
.pgfullscreen #tab_plugin_dashboard {
display: unset;
position: absolute;
bottom: 20px;
left: 20px;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
z-index: 4;
background: rgba(255, 255, 255, 0.2);
opacity: 0.8;
/* width: 300px; */
/* transform: scale(0.8); */
}
.pgfullscreen #tab_plugin_prettygcode .pgdashtoggle {
left: 20px;
bottom: 20px;
position: absolute;
z-index: 10;
display: unset;
}
#tab_plugin_prettygcode .pgdashtoggle {
display: none;
}
.pgfullscreen #tab_plugin_dashboard.pghidden {
display: none;
}
/* Dark Mode Styles */
.pgdarkmode .pgfullscreen {
color: var(--pg-text-dark);
}
.pgdarkmode #state_wrapper {
background: var(--pg-panel-dark);
color: var(--pg-text-dark);
}
.pgdarkmode #files_wrapper {
background: var(--pg-panel-dark);
color: var(--pg-text-dark);
}
.pgdarkmode #tab_plugin_prettygcode #mygui {
background: var(--pg-panel-dark);
}
.pgdarkmode .gwin .dg {
color: var(--pg-text-dark);
}
.pgdarkmode .gwin .dg li:not(.folder) {
background: var(--pg-input-dark);
border-bottom: 1px solid var(--pg-border-dark);
}
.pgdarkmode .gwin .dg li.title {
background: #333 url(data:image/gif;base64,R0lGODlhBQAFAJEAAP////Pz8////////yH5BAEAAAIALAAAAAAFAAUAAAIIlI+hKgFxoCgAOw==) 6px 10px no-repeat;
}
.pgdarkmode .gwin .dg .cr.function:hover,
.pgdarkmode .gwin .dg .cr.boolean:hover {
background: #3a3a3a;
}
.pgdarkmode .gwin .dg .c input[type=text] {
background: var(--pg-input-dark);
color: var(--pg-text-dark);
}
.pgdarkmode .gwin .dg .c input[type=text]:hover {
background: #333;
}
.pgdarkmode .gwin .dg .c input[type=text]:focus {
background: #333;
color: var(--pg-text-dark);
}
.pgdarkmode .gwin .dg .c .slider {
background: var(--pg-input-dark);
}
.pgdarkmode .gwin .dg .c .slider:hover {
background: #333;
}
.pgdarkmode .gwin .dg.main .close-button {
background-color: #333;
}
.pgdarkmode .gwin .dg.main .close-button:hover {
background-color: #444;
}
.pgdarkmode .gwin .dg.main::-webkit-scrollbar {
background: #1e1e1e;
}
.pgdarkmode .gwin .dg.main::-webkit-scrollbar-thumb {
background: #555;
}
.pgdarkmode #tab_plugin_dashboard {
background: var(--pg-panel-dark);
color: var(--pg-text-dark);
}
.pgdarkmode .pgstatus {
background: rgba(30, 30, 30, 0.5);
color: var(--pg-text-dark);
}
-232
View File
@@ -1,232 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- CSP: all scripts are local; unsafe-eval needed by Three.js shader compiler;
unsafe-inline needed by dat.GUI and jQuery for inline styles/event handlers -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' ws: wss:; font-src 'self' data:; worker-src blob:;">
<title>PrettyGCode — Bambuddy</title>
<link rel="stylesheet" href="css/prettygcode.css">
<style>
*, *::before, *::after { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
height: 100%;
background: #1a1a1a;
color: #e0e0e0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 14px;
}
/* Top toolbar */
#bb-toolbar {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 10px;
background: #111;
border-bottom: 1px solid #333;
flex-shrink: 0;
flex-wrap: wrap;
}
#bb-current-file {
color: #9ecfff; font-size: 12px;
max-width: 300px; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap;
}
#bb-open-settings {
margin-left: auto;
background: none; color: #aaa;
border: 1px solid #555; border-radius: 4px;
padding: 3px 8px; cursor: pointer; font-size: 12px;
}
#bb-open-settings:hover { color: #fff; border-color: #888; }
/* Playback controls */
#bb-play-btn {
background: #2a7a4a; color: #fff;
border: none; border-radius: 4px;
padding: 4px 10px; cursor: pointer; font-size: 14px;
min-width: 34px;
}
#bb-play-btn:hover { background: #1d5c38; }
#bb-play-btn:disabled { background: #444; cursor: default; }
#bb-play-speed {
background: #222; color: #ddd;
border: 1px solid #444; border-radius: 4px;
padding: 3px 5px; font-size: 12px;
}
/* Main layout */
#bb-layout {
display: flex;
flex-direction: column;
height: 100vh;
}
#bb-viewer-wrap {
flex: 1;
position: relative;
overflow: hidden;
}
/* The prettygcode container — must fill its parent */
.page-container {
width: 100%;
height: 100%;
position: relative;
}
#tab_plugin_prettygcode {
width: 100%;
height: 100%;
}
.gwin {
width: 100%;
height: 100%;
position: relative;
}
#mycanvas {
width: 100%;
height: 100%;
display: block;
}
/* Webcam element cloned by prettygcode.js — keep hidden until fullscreen */
#webcam_rotator {
display: none;
}
#webcam_image { max-width: 100%; }
/* Control buttons inside .gwin */
.pgstatetoggle, .pgfilestoggle, .pgsettingstoggle, .fstoggle,
.pgdashtoggle, .pgcameratoggle {
background: rgba(0,0,0,0.5);
border: 1px solid #666;
border-radius: 4px;
color: #ddd;
cursor: pointer;
font-size: 16px;
padding: 4px 7px;
z-index: 10;
}
.pgstatetoggle:hover, .pgfilestoggle:hover, .pgsettingstoggle:hover,
.fstoggle:hover, .pgdashtoggle:hover, .pgcameratoggle:hover {
background: rgba(60,60,60,0.8);
}
/* Slider shim styles */
.slider {
position: absolute;
right: 20px;
top: 5%;
height: 90%;
width: 28px;
z-index: 10;
cursor: pointer;
user-select: none;
}
.slider-vertical { writing-mode: vertical-lr; }
.slider-track {
position: absolute;
left: 50%;
top: 0; bottom: 0;
width: 4px;
transform: translateX(-50%);
background: #555;
border-radius: 2px;
}
.slider-selection {
position: absolute;
left: 0; right: 0;
bottom: 0;
background: #4a9;
border-radius: 2px;
}
.slider-handle {
position: absolute;
left: 50%;
transform: translateX(-50%);
width: 24px; height: 24px;
background: #6cf;
border-radius: 50%;
line-height: 24px;
text-align: center;
font-size: 9px;
color: #111;
font-weight: bold;
}
</style>
</head>
<body>
<div id="bb-layout">
<!-- Toolbar -->
<div id="bb-toolbar">
<span id="bb-current-file">— no file loaded —</span>
<button id="bb-play-btn" title="Play layer animation" disabled>&#9654;</button>
<select id="bb-play-speed" title="Playback speed">
<option value="1">1&#215; slow</option>
<option value="3" selected>3&#215;</option>
<option value="10">10&#215; fast</option>
<option value="25">25&#215; turbo</option>
</select>
</div>
<!-- Viewer area -->
<div id="bb-viewer-wrap">
<div class="page-container">
<div id="tab_plugin_prettygcode">
<div class="gwin">
<canvas id="mycanvas"></canvas>
<div id="pgstatus" class="pgstatus">T:0/0 B:0/0</div>
<div id="mygui" class="pghidden">View Options</div>
<button class="pgstatetoggle" title="Toggle state window">&#9432;</button>
<button class="pgfilestoggle" title="Toggle file list">&#9776;</button>
<button class="pgsettingstoggle" title="Toggle settings">&#9881;</button>
<button class="pgcameratoggle" title="Toggle webcam">&#128247;</button>
</div>
</div>
<!-- Webcam source element — prettygcode.js clones this into .gwin -->
<div id="webcam_rotator" style="display:none;">
<img id="webcam_image" src="" alt="webcam">
</div>
</div>
</div><!-- /bb-viewer-wrap -->
</div><!-- /bb-layout -->
<!-- Scripts — load order matters -->
<script src="js/jquery.min.js"></script>
<script src="js/slider-shim.js"></script>
<script src="js/three.min.js"></script>
<script src="js/LineSegmentsGeometry.js"></script>
<script src="js/LineGeometry.js"></script>
<script src="js/LineMaterial.js"></script>
<script src="js/LineSegments2.js"></script>
<script src="js/Line2.js"></script>
<script src="js/Lut.js"></script>
<script src="js/OBJLoader.js"></script>
<script src="js/camera-controls.js"></script>
<script src="js/dat.gui.js"></script>
<!-- Adapter MUST load before prettygcode.js -->
<script src="js/bambuddy_adapter.js"></script>
<script src="js/prettygcode.js"></script>
<!-- Button wiring is in bambuddy_adapter.js — inline scripts are blocked by the
script-src CSP on this page (no 'unsafe-inline'). -->
</body>
</html>
-31
View File
@@ -1,31 +0,0 @@
/**
* @author WestLangley / http://github.com/WestLangley
*
*/
THREE.Line2 = function ( geometry, material ) {
THREE.LineSegments2.call( this );
this.type = 'Line2';
this.geometry = geometry !== undefined ? geometry : new THREE.LineGeometry();
this.material = material !== undefined ? material : new THREE.LineMaterial( { color: Math.random() * 0xffffff } );
};
THREE.Line2.prototype = Object.assign( Object.create( THREE.LineSegments2.prototype ), {
constructor: THREE.Line2,
isLine2: true,
copy: function ( /* source */ ) {
// todo
return this;
}
} );
-104
View File
@@ -1,104 +0,0 @@
/**
* @author WestLangley / http://github.com/WestLangley
*
*/
THREE.LineGeometry = function () {
THREE.LineSegmentsGeometry.call( this );
this.type = 'LineGeometry';
};
THREE.LineGeometry.prototype = Object.assign(Object.create(THREE.LineSegmentsGeometry.prototype), {
constructor: THREE.LineGeometry,
isLineGeometry: true,
setPositions: function (array, unpack = false ) {
// converts [ x1, y1, z1, x2, y2, z2, ... ] to pairs format
if (unpack) {
var length = array.length - 3;
var points = new Float32Array(2 * length);
for (var i = 0; i < length; i += 3) {
points[2 * i] = array[i];
points[2 * i + 1] = array[i + 1];
points[2 * i + 2] = array[i + 2];
points[2 * i + 3] = array[i + 3];
points[2 * i + 4] = array[i + 4];
points[2 * i + 5] = array[i + 5];
}
THREE.LineSegmentsGeometry.prototype.setPositions.call(this, points);
}
else
THREE.LineSegmentsGeometry.prototype.setPositions.call(this, array);
return this;
},
setColors: function (array, unpack = false ) {
// converts [ r1, g1, b1, r2, g2, b2, ... ] to pairs format
if (unpack) {
var length = array.length - 3;
var colors = new Float32Array( 2 * length );
for ( var i = 0; i < length; i += 3 ) {
colors[ 2 * i ] = array[ i ];
colors[ 2 * i + 1 ] = array[ i + 1 ];
colors[ 2 * i + 2 ] = array[ i + 2 ];
colors[ 2 * i + 3 ] = array[ i + 3 ];
colors[ 2 * i + 4 ] = array[ i + 4 ];
colors[ 2 * i + 5 ] = array[ i + 5 ];
}
THREE.LineSegmentsGeometry.prototype.setColors.call( this, colors );
}
else
THREE.LineSegmentsGeometry.prototype.setColors.call(this, array);
return this;
},
fromLine: function ( line ) {
var geometry = line.geometry;
if ( geometry.isGeometry ) {
this.setPositions( geometry.vertices );
} else if ( geometry.isBufferGeometry ) {
this.setPositions( geometry.position.array ); // assumes non-indexed
}
// set colors, maybe
return this;
},
copy: function ( /* source */ ) {
// todo
return this;
}
} );
-391
View File
@@ -1,391 +0,0 @@
/**
* @author WestLangley / http://github.com/WestLangley
*
* parameters = {
* color: <hex>,
* linewidth: <float>,
* dashed: <boolean>,
* dashScale: <float>,
* dashSize: <float>,
* gapSize: <float>,
* resolution: <Vector2>, // to be set by renderer
* }
*/
THREE.UniformsLib.line = {
linewidth: { value: 1 },
resolution: { value: new THREE.Vector2( 1, 1 ) },
dashScale: { value: 1 },
dashSize: { value: 1 },
gapSize: { value: 1 } // todo FIX - maybe change to totalSize
};
THREE.ShaderLib[ 'line' ] = {
uniforms: THREE.UniformsUtils.merge( [
THREE.UniformsLib.common,
THREE.UniformsLib.fog,
THREE.UniformsLib.line
] ),
vertexShader:
`
#include <common>
#include <color_pars_vertex>
#include <fog_pars_vertex>
#include <logdepthbuf_pars_vertex>
#include <clipping_planes_pars_vertex>
uniform float linewidth;
uniform vec2 resolution;
attribute vec3 instanceStart;
attribute vec3 instanceEnd;
attribute vec3 instanceColorStart;
attribute vec3 instanceColorEnd;
varying vec2 vUv;
#ifdef USE_DASH
uniform float dashScale;
attribute float instanceDistanceStart;
attribute float instanceDistanceEnd;
varying float vLineDistance;
#endif
void trimSegment( const in vec4 start, inout vec4 end ) {
// trim end segment so it terminates between the camera plane and the near plane
// conservative estimate of the near plane
float a = projectionMatrix[ 2 ][ 2 ]; // 3nd entry in 3th column
float b = projectionMatrix[ 3 ][ 2 ]; // 3nd entry in 4th column
float nearEstimate = - 0.5 * b / a;
float alpha = ( nearEstimate - start.z ) / ( end.z - start.z );
end.xyz = mix( start.xyz, end.xyz, alpha );
}
void main() {
#ifdef USE_COLOR
vColor.xyz = ( position.y < 0.5 ) ? instanceColorStart : instanceColorEnd;
#endif
#ifdef USE_DASH
vLineDistance = ( position.y < 0.5 ) ? dashScale * instanceDistanceStart : dashScale * instanceDistanceEnd;
#endif
float aspect = resolution.x / resolution.y;
vUv = uv;
// camera space
vec4 start = modelViewMatrix * vec4( instanceStart, 1.0 );
vec4 end = modelViewMatrix * vec4( instanceEnd, 1.0 );
// special case for perspective projection, and segments that terminate either in, or behind, the camera plane
// clearly the gpu firmware has a way of addressing this issue when projecting into ndc space
// but we need to perform ndc-space calculations in the shader, so we must address this issue directly
// perhaps there is a more elegant solution -- WestLangley
bool perspective = ( projectionMatrix[ 2 ][ 3 ] == - 1.0 ); // 4th entry in the 3rd column
if ( perspective ) {
if ( start.z < 0.0 && end.z >= 0.0 ) {
trimSegment( start, end );
} else if ( end.z < 0.0 && start.z >= 0.0 ) {
trimSegment( end, start );
}
}
// clip space
vec4 clipStart = projectionMatrix * start;
vec4 clipEnd = projectionMatrix * end;
// ndc space
vec2 ndcStart = clipStart.xy / clipStart.w;
vec2 ndcEnd = clipEnd.xy / clipEnd.w;
// direction
vec2 dir = ndcEnd - ndcStart;
// account for clip-space aspect ratio
dir.x *= aspect;
dir = normalize( dir );
// perpendicular to dir
vec2 offset = vec2( dir.y, - dir.x );
// undo aspect ratio adjustment
dir.x /= aspect;
offset.x /= aspect;
// sign flip
if ( position.x < 0.0 ) offset *= - 1.0;
// endcaps
if ( position.y < 0.0 ) {
offset += - dir;
} else if ( position.y > 1.0 ) {
offset += dir;
}
// adjust for linewidth
offset *= linewidth;
// adjust for clip-space to screen-space conversion // maybe resolution should be based on viewport ...
offset /= resolution.y;
// select end
vec4 clip = ( position.y < 0.5 ) ? clipStart : clipEnd;
// back to clip space
offset *= clip.w;
clip.xy += offset;
gl_Position = clip;
vec4 mvPosition = ( position.y < 0.5 ) ? start : end; // this is an approximation
#include <logdepthbuf_vertex>
#include <clipping_planes_vertex>
#include <fog_vertex>
}
`,
fragmentShader:
`
uniform vec3 diffuse;
uniform float opacity;
#ifdef USE_DASH
uniform float dashSize;
uniform float gapSize;
#endif
varying float vLineDistance;
#include <common>
#include <color_pars_fragment>
#include <fog_pars_fragment>
#include <logdepthbuf_pars_fragment>
#include <clipping_planes_pars_fragment>
varying vec2 vUv;
void main() {
#include <clipping_planes_fragment>
#ifdef USE_DASH
if ( vUv.y < - 1.0 || vUv.y > 1.0 ) discard; // discard endcaps
if ( mod( vLineDistance, dashSize + gapSize ) > dashSize ) discard; // todo - FIX
#endif
if ( abs( vUv.y ) > 1.0 ) {
float a = vUv.x;
float b = ( vUv.y > 0.0 ) ? vUv.y - 1.0 : vUv.y + 1.0;
float len2 = a * a + b * b;
if ( len2 > 1.0 ) discard;
}
vec4 diffuseColor = vec4( diffuse, opacity );
#include <logdepthbuf_fragment>
#include <color_fragment>
gl_FragColor = vec4( diffuseColor.rgb, diffuseColor.a );
#include <premultiplied_alpha_fragment>
#include <tonemapping_fragment>
#include <encodings_fragment>
#include <fog_fragment>
}
`
};
THREE.LineMaterial = function ( parameters ) {
THREE.ShaderMaterial.call( this, {
type: 'LineMaterial',
uniforms: THREE.UniformsUtils.clone( THREE.ShaderLib[ 'line' ].uniforms ),
vertexShader: THREE.ShaderLib[ 'line' ].vertexShader,
fragmentShader: THREE.ShaderLib[ 'line' ].fragmentShader
} );
this.dashed = false;
Object.defineProperties( this, {
color: {
enumerable: true,
get: function () {
return this.uniforms.diffuse.value;
},
set: function ( value ) {
this.uniforms.diffuse.value = value;
}
},
linewidth: {
enumerable: true,
get: function () {
return this.uniforms.linewidth.value;
},
set: function ( value ) {
this.uniforms.linewidth.value = value;
}
},
dashScale: {
enumerable: true,
get: function () {
return this.uniforms.dashScale.value;
},
set: function ( value ) {
this.uniforms.dashScale.value = value;
}
},
dashSize: {
enumerable: true,
get: function () {
return this.uniforms.dashSize.value;
},
set: function ( value ) {
this.uniforms.dashSize.value = value;
}
},
gapSize: {
enumerable: true,
get: function () {
return this.uniforms.gapSize.value;
},
set: function ( value ) {
this.uniforms.gapSize.value = value;
}
},
resolution: {
enumerable: true,
get: function () {
return this.uniforms.resolution.value;
},
set: function ( value ) {
this.uniforms.resolution.value.copy( value );
}
}
} );
this.setValues( parameters );
};
THREE.LineMaterial.prototype = Object.create( THREE.ShaderMaterial.prototype );
THREE.LineMaterial.prototype.constructor = THREE.LineMaterial;
THREE.LineMaterial.prototype.isLineMaterial = true;
THREE.LineMaterial.prototype.copy = function ( source ) {
THREE.ShaderMaterial.prototype.copy.call( this, source );
this.color.copy( source.color );
this.linewidth = source.linewidth;
this.resolution = source.resolution;
// todo
return this;
};
-65
View File
@@ -1,65 +0,0 @@
/**
* @author WestLangley / http://github.com/WestLangley
*
*/
THREE.LineSegments2 = function ( geometry, material ) {
THREE.Mesh.call( this );
this.type = 'LineSegments2';
this.geometry = geometry !== undefined ? geometry : new THREE.LineSegmentsGeometry();
this.material = material !== undefined ? material : new THREE.LineMaterial( { color: Math.random() * 0xffffff } );
};
THREE.LineSegments2.prototype = Object.assign( Object.create( THREE.Mesh.prototype ), {
constructor: THREE.LineSegments2,
isLineSegments2: true,
computeLineDistances: ( function () { // for backwards-compatability, but could be a method of LineSegmentsGeometry...
var start = new THREE.Vector3();
var end = new THREE.Vector3();
return function computeLineDistances() {
var geometry = this.geometry;
var instanceStart = geometry.attributes.instanceStart;
var instanceEnd = geometry.attributes.instanceEnd;
var lineDistances = new Float32Array( 2 * instanceStart.data.count );
for ( var i = 0, j = 0, l = instanceStart.data.count; i < l; i ++, j += 2 ) {
start.fromBufferAttribute( instanceStart, i );
end.fromBufferAttribute( instanceEnd, i );
lineDistances[ j ] = ( j === 0 ) ? 0 : lineDistances[ j - 1 ];
lineDistances[ j + 1 ] = lineDistances[ j ] + start.distanceTo( end );
}
var instanceDistanceBuffer = new THREE.InstancedInterleavedBuffer( lineDistances, 2, 1 ); // d0, d1
geometry.addAttribute( 'instanceDistanceStart', new THREE.InterleavedBufferAttribute( instanceDistanceBuffer, 1, 0 ) ); // d0
geometry.addAttribute( 'instanceDistanceEnd', new THREE.InterleavedBufferAttribute( instanceDistanceBuffer, 1, 1 ) ); // d1
return this;
};
}() ),
copy: function ( /* source */ ) {
// todo
return this;
}
} );
-258
View File
@@ -1,258 +0,0 @@
/**
* @author WestLangley / http://github.com/WestLangley
*
*/
THREE.LineSegmentsGeometry = function () {
THREE.InstancedBufferGeometry.call( this );
this.type = 'LineSegmentsGeometry';
var positions = [ - 1, 2, 0, 1, 2, 0, - 1, 1, 0, 1, 1, 0, - 1, 0, 0, 1, 0, 0, - 1, - 1, 0, 1, - 1, 0 ];
var uvs = [ - 1, 2, 1, 2, - 1, 1, 1, 1, - 1, - 1, 1, - 1, - 1, - 2, 1, - 2 ];
var index = [ 0, 2, 1, 2, 3, 1, 2, 4, 3, 4, 5, 3, 4, 6, 5, 6, 7, 5 ];
this.setIndex( index );
this.addAttribute( 'position', new THREE.Float32BufferAttribute( positions, 3 ) );
this.addAttribute( 'uv', new THREE.Float32BufferAttribute( uvs, 2 ) );
};
THREE.LineSegmentsGeometry.prototype = Object.assign( Object.create( THREE.InstancedBufferGeometry.prototype ), {
constructor: THREE.LineSegmentsGeometry,
isLineSegmentsGeometry: true,
applyMatrix: function ( matrix ) {
var start = this.attributes.instanceStart;
var end = this.attributes.instanceEnd;
if ( start !== undefined ) {
matrix.applyToBufferAttribute( start );
matrix.applyToBufferAttribute( end );
start.data.needsUpdate = true;
}
if ( this.boundingBox !== null ) {
this.computeBoundingBox();
}
if ( this.boundingSphere !== null ) {
this.computeBoundingSphere();
}
return this;
},
setPositions: function ( array ) {
var lineSegments;
if ( array instanceof Float32Array ) {
lineSegments = array;
} else if ( Array.isArray( array ) ) {
lineSegments = new Float32Array( array );
}
var instanceBuffer = new THREE.InstancedInterleavedBuffer( lineSegments, 6, 1 ); // xyz, xyz
this.addAttribute( 'instanceStart', new THREE.InterleavedBufferAttribute( instanceBuffer, 3, 0 ) ); // xyz
this.addAttribute( 'instanceEnd', new THREE.InterleavedBufferAttribute( instanceBuffer, 3, 3 ) ); // xyz
//
this.computeBoundingBox();
this.computeBoundingSphere();
return this;
},
setColors: function ( array ) {
var colors;
if ( array instanceof Float32Array ) {
colors = array;
} else if ( Array.isArray( array ) ) {
colors = new Float32Array( array );
}
var instanceColorBuffer = new THREE.InstancedInterleavedBuffer( colors, 6, 1 ); // rgb, rgb
this.addAttribute( 'instanceColorStart', new THREE.InterleavedBufferAttribute( instanceColorBuffer, 3, 0 ) ); // rgb
this.addAttribute( 'instanceColorEnd', new THREE.InterleavedBufferAttribute( instanceColorBuffer, 3, 3 ) ); // rgb
return this;
},
fromWireframeGeometry: function ( geometry ) {
this.setPositions( geometry.attributes.position.array );
return this;
},
fromEdgesGeometry: function ( geometry ) {
this.setPositions( geometry.attributes.position.array );
return this;
},
fromMesh: function ( mesh ) {
this.fromWireframeGeometry( new THREE.WireframeGeometry( mesh.geometry ) );
// set colors, maybe
return this;
},
fromLineSegements: function ( lineSegments ) {
var geometry = lineSegments.geometry;
if ( geometry.isGeometry ) {
this.setPositions( geometry.vertices );
} else if ( geometry.isBufferGeometry ) {
this.setPositions( geometry.position.array ); // assumes non-indexed
}
// set colors, maybe
return this;
},
computeBoundingBox: function () {
var box = new THREE.Box3();
return function computeBoundingBox() {
if ( this.boundingBox === null ) {
this.boundingBox = new THREE.Box3();
}
var start = this.attributes.instanceStart;
var end = this.attributes.instanceEnd;
if ( start !== undefined && end !== undefined ) {
this.boundingBox.setFromBufferAttribute( start );
box.setFromBufferAttribute( end );
this.boundingBox.union( box );
}
};
}(),
computeBoundingSphere: function () {
var vector = new THREE.Vector3();
return function computeBoundingSphere() {
if ( this.boundingSphere === null ) {
this.boundingSphere = new THREE.Sphere();
}
if ( this.boundingBox === null ) {
this.computeBoundingBox();
}
var start = this.attributes.instanceStart;
var end = this.attributes.instanceEnd;
if ( start !== undefined && end !== undefined ) {
var center = this.boundingSphere.center;
this.boundingBox.getCenter( center );
var maxRadiusSq = 0;
for ( var i = 0, il = start.count; i < il; i ++ ) {
vector.fromBufferAttribute( start, i );
maxRadiusSq = Math.max( maxRadiusSq, center.distanceToSquared( vector ) );
vector.fromBufferAttribute( end, i );
maxRadiusSq = Math.max( maxRadiusSq, center.distanceToSquared( vector ) );
}
this.boundingSphere.radius = Math.sqrt( maxRadiusSq );
if ( isNaN( this.boundingSphere.radius ) ) {
console.error( 'THREE.LineSegmentsGeometry.computeBoundingSphere(): Computed radius is NaN. The instanced position data is likely to have NaN values.', this );
}
}
};
}(),
toJSON: function () {
// todo
},
clone: function () {
// todo
},
copy: function ( /* source */ ) {
// todo
return this;
}
} );
-185
View File
@@ -1,185 +0,0 @@
/**
* @author daron1337 / http://daron1337.github.io/
*/
THREE.Lut = function ( colormap, numberofcolors ) {
this.lut = [];
this.setColorMap( colormap, numberofcolors );
return this;
};
THREE.Lut.prototype = {
constructor: THREE.Lut,
lut: [], map: [], n: 256, minV: 0, maxV: 1,
set: function ( value ) {
if ( value instanceof THREE.Lut ) {
this.copy( value );
}
return this;
},
setMin: function ( min ) {
this.minV = min;
return this;
},
setMax: function ( max ) {
this.maxV = max;
return this;
},
setColorMap: function ( colormap, numberofcolors ) {
this.map = THREE.ColorMapKeywords[ colormap ] || THREE.ColorMapKeywords.rainbow;
this.n = numberofcolors || 32;
var step = 1.0 / this.n;
this.lut.length = 0;
for ( var i = 0; i <= 1; i += step ) {
for ( var j = 0; j < this.map.length - 1; j ++ ) {
if ( i >= this.map[ j ][ 0 ] && i < this.map[ j + 1 ][ 0 ] ) {
var min = this.map[ j ][ 0 ];
var max = this.map[ j + 1 ][ 0 ];
var minColor = new THREE.Color( this.map[ j ][ 1 ] );
var maxColor = new THREE.Color( this.map[ j + 1 ][ 1 ] );
var color = minColor.lerp( maxColor, ( i - min ) / ( max - min ) );
this.lut.push( color );
}
}
}
return this;
},
copy: function ( lut ) {
this.lut = lut.lut;
this.map = lut.map;
this.n = lut.n;
this.minV = lut.minV;
this.maxV = lut.maxV;
return this;
},
getColor: function ( alpha ) {
if ( alpha <= this.minV ) {
alpha = this.minV;
} else if ( alpha >= this.maxV ) {
alpha = this.maxV;
}
alpha = ( alpha - this.minV ) / ( this.maxV - this.minV );
var colorPosition = Math.round( alpha * this.n );
colorPosition == this.n ? colorPosition -= 1 : colorPosition;
return this.lut[ colorPosition ];
},
addColorMap: function ( colormapName, arrayOfColors ) {
THREE.ColorMapKeywords[ colormapName ] = arrayOfColors;
},
createCanvas: function () {
var canvas = document.createElement( 'canvas' );
canvas.width = 1;
canvas.height = this.n;
this.updateCanvas( canvas );
return canvas;
},
updateCanvas: function ( canvas ) {
var ctx = canvas.getContext( '2d', { alpha: false } );
var imageData = ctx.getImageData( 0, 0, 1, this.n );
var data = imageData.data;
var k = 0;
var step = 1.0 / this.n;
for ( var i = 1; i >= 0; i -= step ) {
for ( var j = this.map.length - 1; j >= 0; j -- ) {
if ( i < this.map[ j ][ 0 ] && i >= this.map[ j - 1 ][ 0 ] ) {
var min = this.map[ j - 1 ][ 0 ];
var max = this.map[ j ][ 0 ];
var minColor = new THREE.Color( this.map[ j - 1 ][ 1 ] );
var maxColor = new THREE.Color( this.map[ j ][ 1 ] );
var color = minColor.lerp( maxColor, ( i - min ) / ( max - min ) );
data[ k * 4 ] = Math.round( color.r * 255 );
data[ k * 4 + 1 ] = Math.round( color.g * 255 );
data[ k * 4 + 2 ] = Math.round( color.b * 255 );
data[ k * 4 + 3 ] = 255;
k += 1;
}
}
}
ctx.putImageData( imageData, 0, 0 );
return canvas;
}
};
THREE.ColorMapKeywords = {
"rainbow": [[ 0.0, 0x0000FF ], [ 0.2, 0x00FFFF ], [ 0.5, 0x00FF00 ], [ 0.8, 0xFFFF00 ], [ 1.0, 0xFF0000 ]],
"cooltowarm": [[ 0.0, 0x3C4EC2 ], [ 0.2, 0x9BBCFF ], [ 0.5, 0xDCDCDC ], [ 0.8, 0xF6A385 ], [ 1.0, 0xB40426 ]],
"blackbody": [[ 0.0, 0x000000 ], [ 0.2, 0x780000 ], [ 0.5, 0xE63200 ], [ 0.8, 0xFFFF00 ], [ 1.0, 0xFFFFFF ]],
"grayscale": [[ 0.0, 0x000000 ], [ 0.2, 0x404040 ], [ 0.5, 0x7F7F80 ], [ 0.8, 0xBFBFBF ], [ 1.0, 0xFFFFFF ]]
};
-797
View File
@@ -1,797 +0,0 @@
/**
* @author mrdoob / http://mrdoob.com/
*/
THREE.OBJLoader = ( function () {
// o object_name | g group_name
var object_pattern = /^[og]\s*(.+)?/;
// mtllib file_reference
var material_library_pattern = /^mtllib /;
// usemtl material_name
var material_use_pattern = /^usemtl /;
function ParserState() {
var state = {
objects: [],
object: {},
vertices: [],
normals: [],
colors: [],
uvs: [],
materialLibraries: [],
startObject: function ( name, fromDeclaration ) {
// If the current object (initial from reset) is not from a g/o declaration in the parsed
// file. We need to use it for the first parsed g/o to keep things in sync.
if ( this.object && this.object.fromDeclaration === false ) {
this.object.name = name;
this.object.fromDeclaration = ( fromDeclaration !== false );
return;
}
var previousMaterial = ( this.object && typeof this.object.currentMaterial === 'function' ? this.object.currentMaterial() : undefined );
if ( this.object && typeof this.object._finalize === 'function' ) {
this.object._finalize( true );
}
this.object = {
name: name || '',
fromDeclaration: ( fromDeclaration !== false ),
geometry: {
vertices: [],
normals: [],
colors: [],
uvs: []
},
materials: [],
smooth: true,
startMaterial: function ( name, libraries ) {
var previous = this._finalize( false );
// New usemtl declaration overwrites an inherited material, except if faces were declared
// after the material, then it must be preserved for proper MultiMaterial continuation.
if ( previous && ( previous.inherited || previous.groupCount <= 0 ) ) {
this.materials.splice( previous.index, 1 );
}
var material = {
index: this.materials.length,
name: name || '',
mtllib: ( Array.isArray( libraries ) && libraries.length > 0 ? libraries[ libraries.length - 1 ] : '' ),
smooth: ( previous !== undefined ? previous.smooth : this.smooth ),
groupStart: ( previous !== undefined ? previous.groupEnd : 0 ),
groupEnd: - 1,
groupCount: - 1,
inherited: false,
clone: function ( index ) {
var cloned = {
index: ( typeof index === 'number' ? index : this.index ),
name: this.name,
mtllib: this.mtllib,
smooth: this.smooth,
groupStart: 0,
groupEnd: - 1,
groupCount: - 1,
inherited: false
};
cloned.clone = this.clone.bind( cloned );
return cloned;
}
};
this.materials.push( material );
return material;
},
currentMaterial: function () {
if ( this.materials.length > 0 ) {
return this.materials[ this.materials.length - 1 ];
}
return undefined;
},
_finalize: function ( end ) {
var lastMultiMaterial = this.currentMaterial();
if ( lastMultiMaterial && lastMultiMaterial.groupEnd === - 1 ) {
lastMultiMaterial.groupEnd = this.geometry.vertices.length / 3;
lastMultiMaterial.groupCount = lastMultiMaterial.groupEnd - lastMultiMaterial.groupStart;
lastMultiMaterial.inherited = false;
}
// Ignore objects tail materials if no face declarations followed them before a new o/g started.
if ( end && this.materials.length > 1 ) {
for ( var mi = this.materials.length - 1; mi >= 0; mi -- ) {
if ( this.materials[ mi ].groupCount <= 0 ) {
this.materials.splice( mi, 1 );
}
}
}
// Guarantee at least one empty material, this makes the creation later more straight forward.
if ( end && this.materials.length === 0 ) {
this.materials.push( {
name: '',
smooth: this.smooth
} );
}
return lastMultiMaterial;
}
};
// Inherit previous objects material.
// Spec tells us that a declared material must be set to all objects until a new material is declared.
// If a usemtl declaration is encountered while this new object is being parsed, it will
// overwrite the inherited material. Exception being that there was already face declarations
// to the inherited material, then it will be preserved for proper MultiMaterial continuation.
if ( previousMaterial && previousMaterial.name && typeof previousMaterial.clone === 'function' ) {
var declared = previousMaterial.clone( 0 );
declared.inherited = true;
this.object.materials.push( declared );
}
this.objects.push( this.object );
},
finalize: function () {
if ( this.object && typeof this.object._finalize === 'function' ) {
this.object._finalize( true );
}
},
parseVertexIndex: function ( value, len ) {
var index = parseInt( value, 10 );
return ( index >= 0 ? index - 1 : index + len / 3 ) * 3;
},
parseNormalIndex: function ( value, len ) {
var index = parseInt( value, 10 );
return ( index >= 0 ? index - 1 : index + len / 3 ) * 3;
},
parseUVIndex: function ( value, len ) {
var index = parseInt( value, 10 );
return ( index >= 0 ? index - 1 : index + len / 2 ) * 2;
},
addVertex: function ( a, b, c ) {
var src = this.vertices;
var dst = this.object.geometry.vertices;
dst.push( src[ a + 0 ], src[ a + 1 ], src[ a + 2 ] );
dst.push( src[ b + 0 ], src[ b + 1 ], src[ b + 2 ] );
dst.push( src[ c + 0 ], src[ c + 1 ], src[ c + 2 ] );
},
addVertexPoint: function ( a ) {
var src = this.vertices;
var dst = this.object.geometry.vertices;
dst.push( src[ a + 0 ], src[ a + 1 ], src[ a + 2 ] );
},
addVertexLine: function ( a ) {
var src = this.vertices;
var dst = this.object.geometry.vertices;
dst.push( src[ a + 0 ], src[ a + 1 ], src[ a + 2 ] );
},
addNormal: function ( a, b, c ) {
var src = this.normals;
var dst = this.object.geometry.normals;
dst.push( src[ a + 0 ], src[ a + 1 ], src[ a + 2 ] );
dst.push( src[ b + 0 ], src[ b + 1 ], src[ b + 2 ] );
dst.push( src[ c + 0 ], src[ c + 1 ], src[ c + 2 ] );
},
addColor: function ( a, b, c ) {
var src = this.colors;
var dst = this.object.geometry.colors;
dst.push( src[ a + 0 ], src[ a + 1 ], src[ a + 2 ] );
dst.push( src[ b + 0 ], src[ b + 1 ], src[ b + 2 ] );
dst.push( src[ c + 0 ], src[ c + 1 ], src[ c + 2 ] );
},
addUV: function ( a, b, c ) {
var src = this.uvs;
var dst = this.object.geometry.uvs;
dst.push( src[ a + 0 ], src[ a + 1 ] );
dst.push( src[ b + 0 ], src[ b + 1 ] );
dst.push( src[ c + 0 ], src[ c + 1 ] );
},
addUVLine: function ( a ) {
var src = this.uvs;
var dst = this.object.geometry.uvs;
dst.push( src[ a + 0 ], src[ a + 1 ] );
},
addFace: function ( a, b, c, ua, ub, uc, na, nb, nc ) {
var vLen = this.vertices.length;
var ia = this.parseVertexIndex( a, vLen );
var ib = this.parseVertexIndex( b, vLen );
var ic = this.parseVertexIndex( c, vLen );
this.addVertex( ia, ib, ic );
if ( ua !== undefined && ua !== '' ) {
var uvLen = this.uvs.length;
ia = this.parseUVIndex( ua, uvLen );
ib = this.parseUVIndex( ub, uvLen );
ic = this.parseUVIndex( uc, uvLen );
this.addUV( ia, ib, ic );
}
if ( na !== undefined && na !== '' ) {
// Normals are many times the same. If so, skip function call and parseInt.
var nLen = this.normals.length;
ia = this.parseNormalIndex( na, nLen );
ib = na === nb ? ia : this.parseNormalIndex( nb, nLen );
ic = na === nc ? ia : this.parseNormalIndex( nc, nLen );
this.addNormal( ia, ib, ic );
}
if ( this.colors.length > 0 ) {
this.addColor( ia, ib, ic );
}
},
addPointGeometry: function ( vertices ) {
this.object.geometry.type = 'Points';
var vLen = this.vertices.length;
for ( var vi = 0, l = vertices.length; vi < l; vi ++ ) {
this.addVertexPoint( this.parseVertexIndex( vertices[ vi ], vLen ) );
}
},
addLineGeometry: function ( vertices, uvs ) {
this.object.geometry.type = 'Line';
var vLen = this.vertices.length;
var uvLen = this.uvs.length;
for ( var vi = 0, l = vertices.length; vi < l; vi ++ ) {
this.addVertexLine( this.parseVertexIndex( vertices[ vi ], vLen ) );
}
for ( var uvi = 0, l = uvs.length; uvi < l; uvi ++ ) {
this.addUVLine( this.parseUVIndex( uvs[ uvi ], uvLen ) );
}
}
};
state.startObject( '', false );
return state;
}
//
function OBJLoader( manager ) {
this.manager = ( manager !== undefined ) ? manager : THREE.DefaultLoadingManager;
this.materials = null;
}
OBJLoader.prototype = {
constructor: OBJLoader,
load: function ( url, onLoad, onProgress, onError ) {
var scope = this;
var loader = new THREE.FileLoader( scope.manager );
loader.setPath( this.path );
loader.load( url, function ( text ) {
onLoad( scope.parse( text ) );
}, onProgress, onError );
},
setPath: function ( value ) {
this.path = value;
return this;
},
setMaterials: function ( materials ) {
this.materials = materials;
return this;
},
parse: function ( text ) {
console.time( 'OBJLoader' );
var state = new ParserState();
if ( text.indexOf( '\r\n' ) !== - 1 ) {
// This is faster than String.split with regex that splits on both
text = text.replace( /\r\n/g, '\n' );
}
if ( text.indexOf( '\\\n' ) !== - 1 ) {
// join lines separated by a line continuation character (\)
text = text.replace( /\\\n/g, '' );
}
var lines = text.split( '\n' );
var line = '', lineFirstChar = '';
var lineLength = 0;
var result = [];
// Faster to just trim left side of the line. Use if available.
var trimLeft = ( typeof ''.trimLeft === 'function' );
for ( var i = 0, l = lines.length; i < l; i ++ ) {
line = lines[ i ];
line = trimLeft ? line.trimLeft() : line.trim();
lineLength = line.length;
if ( lineLength === 0 ) continue;
lineFirstChar = line.charAt( 0 );
// @todo invoke passed in handler if any
if ( lineFirstChar === '#' ) continue;
if ( lineFirstChar === 'v' ) {
var data = line.split( /\s+/ );
switch ( data[ 0 ] ) {
case 'v':
state.vertices.push(
parseFloat( data[ 1 ] ),
parseFloat( data[ 2 ] ),
parseFloat( data[ 3 ] )
);
if ( data.length >= 7 ) {
state.colors.push(
parseFloat( data[ 4 ] ),
parseFloat( data[ 5 ] ),
parseFloat( data[ 6 ] )
);
}
break;
case 'vn':
state.normals.push(
parseFloat( data[ 1 ] ),
parseFloat( data[ 2 ] ),
parseFloat( data[ 3 ] )
);
break;
case 'vt':
state.uvs.push(
parseFloat( data[ 1 ] ),
parseFloat( data[ 2 ] )
);
break;
}
} else if ( lineFirstChar === 'f' ) {
var lineData = line.substr( 1 ).trim();
var vertexData = lineData.split( /\s+/ );
var faceVertices = [];
// Parse the face vertex data into an easy to work with format
for ( var j = 0, jl = vertexData.length; j < jl; j ++ ) {
var vertex = vertexData[ j ];
if ( vertex.length > 0 ) {
var vertexParts = vertex.split( '/' );
faceVertices.push( vertexParts );
}
}
// Draw an edge between the first vertex and all subsequent vertices to form an n-gon
var v1 = faceVertices[ 0 ];
for ( var j = 1, jl = faceVertices.length - 1; j < jl; j ++ ) {
var v2 = faceVertices[ j ];
var v3 = faceVertices[ j + 1 ];
state.addFace(
v1[ 0 ], v2[ 0 ], v3[ 0 ],
v1[ 1 ], v2[ 1 ], v3[ 1 ],
v1[ 2 ], v2[ 2 ], v3[ 2 ]
);
}
} else if ( lineFirstChar === 'l' ) {
var lineParts = line.substring( 1 ).trim().split( " " );
var lineVertices = [], lineUVs = [];
if ( line.indexOf( "/" ) === - 1 ) {
lineVertices = lineParts;
} else {
for ( var li = 0, llen = lineParts.length; li < llen; li ++ ) {
var parts = lineParts[ li ].split( "/" );
if ( parts[ 0 ] !== "" ) lineVertices.push( parts[ 0 ] );
if ( parts[ 1 ] !== "" ) lineUVs.push( parts[ 1 ] );
}
}
state.addLineGeometry( lineVertices, lineUVs );
} else if ( lineFirstChar === 'p' ) {
var lineData = line.substr( 1 ).trim();
var pointData = lineData.split( " " );
state.addPointGeometry( pointData );
} else if ( ( result = object_pattern.exec( line ) ) !== null ) {
// o object_name
// or
// g group_name
// WORKAROUND: https://bugs.chromium.org/p/v8/issues/detail?id=2869
// var name = result[ 0 ].substr( 1 ).trim();
var name = ( " " + result[ 0 ].substr( 1 ).trim() ).substr( 1 );
state.startObject( name );
} else if ( material_use_pattern.test( line ) ) {
// material
state.object.startMaterial( line.substring( 7 ).trim(), state.materialLibraries );
} else if ( material_library_pattern.test( line ) ) {
// mtl file
state.materialLibraries.push( line.substring( 7 ).trim() );
} else if ( lineFirstChar === 's' ) {
result = line.split( ' ' );
// smooth shading
// @todo Handle files that have varying smooth values for a set of faces inside one geometry,
// but does not define a usemtl for each face set.
// This should be detected and a dummy material created (later MultiMaterial and geometry groups).
// This requires some care to not create extra material on each smooth value for "normal" obj files.
// where explicit usemtl defines geometry groups.
// Example asset: examples/models/obj/cerberus/Cerberus.obj
/*
* http://paulbourke.net/dataformats/obj/
* or
* http://www.cs.utah.edu/~boulos/cs3505/obj_spec.pdf
*
* From chapter "Grouping" Syntax explanation "s group_number":
* "group_number is the smoothing group number. To turn off smoothing groups, use a value of 0 or off.
* Polygonal elements use group numbers to put elements in different smoothing groups. For free-form
* surfaces, smoothing groups are either turned on or off; there is no difference between values greater
* than 0."
*/
if ( result.length > 1 ) {
var value = result[ 1 ].trim().toLowerCase();
state.object.smooth = ( value !== '0' && value !== 'off' );
} else {
// ZBrush can produce "s" lines #11707
state.object.smooth = true;
}
var material = state.object.currentMaterial();
if ( material ) material.smooth = state.object.smooth;
} else {
// Handle null terminated files without exception
if ( line === '\0' ) continue;
throw new Error( 'THREE.OBJLoader: Unexpected line: "' + line + '"' );
}
}
state.finalize();
var container = new THREE.Group();
container.materialLibraries = [].concat( state.materialLibraries );
for ( var i = 0, l = state.objects.length; i < l; i ++ ) {
var object = state.objects[ i ];
var geometry = object.geometry;
var materials = object.materials;
var isLine = ( geometry.type === 'Line' );
var isPoints = ( geometry.type === 'Points' );
var hasVertexColors = false;
// Skip o/g line declarations that did not follow with any faces
if ( geometry.vertices.length === 0 ) continue;
var buffergeometry = new THREE.BufferGeometry();
buffergeometry.addAttribute( 'position', new THREE.Float32BufferAttribute( geometry.vertices, 3 ) );
if ( geometry.normals.length > 0 ) {
buffergeometry.addAttribute( 'normal', new THREE.Float32BufferAttribute( geometry.normals, 3 ) );
} else {
buffergeometry.computeVertexNormals();
}
if ( geometry.colors.length > 0 ) {
hasVertexColors = true;
buffergeometry.addAttribute( 'color', new THREE.Float32BufferAttribute( geometry.colors, 3 ) );
}
if ( geometry.uvs.length > 0 ) {
buffergeometry.addAttribute( 'uv', new THREE.Float32BufferAttribute( geometry.uvs, 2 ) );
}
// Create materials
var createdMaterials = [];
for ( var mi = 0, miLen = materials.length; mi < miLen; mi ++ ) {
var sourceMaterial = materials[ mi ];
var material = undefined;
if ( this.materials !== null ) {
material = this.materials.create( sourceMaterial.name );
// mtl etc. loaders probably can't create line materials correctly, copy properties to a line material.
if ( isLine && material && ! ( material instanceof THREE.LineBasicMaterial ) ) {
var materialLine = new THREE.LineBasicMaterial();
THREE.Material.prototype.copy.call( materialLine, material );
materialLine.color.copy( material.color );
materialLine.lights = false;
material = materialLine;
} else if ( isPoints && material && ! ( material instanceof THREE.PointsMaterial ) ) {
var materialPoints = new THREE.PointsMaterial( { size: 10, sizeAttenuation: false } );
THREE.Material.prototype.copy.call( materialPoints, material );
materialPoints.color.copy( material.color );
materialPoints.map = material.map;
materialPoints.lights = false;
material = materialPoints;
}
}
if ( ! material ) {
if ( isLine ) {
material = new THREE.LineBasicMaterial();
} else if ( isPoints ) {
material = new THREE.PointsMaterial( { size: 1, sizeAttenuation: false } );
} else {
material = new THREE.MeshPhongMaterial();
}
material.name = sourceMaterial.name;
}
material.flatShading = sourceMaterial.smooth ? false : true;
material.vertexColors = hasVertexColors ? THREE.VertexColors : THREE.NoColors;
createdMaterials.push( material );
}
// Create mesh
var mesh;
if ( createdMaterials.length > 1 ) {
for ( var mi = 0, miLen = materials.length; mi < miLen; mi ++ ) {
var sourceMaterial = materials[ mi ];
buffergeometry.addGroup( sourceMaterial.groupStart, sourceMaterial.groupCount, mi );
}
if ( isLine ) {
mesh = new THREE.LineSegments( buffergeometry, createdMaterials );
} else if ( isPoints ) {
mesh = new THREE.Points( buffergeometry, createdMaterials );
} else {
mesh = new THREE.Mesh( buffergeometry, createdMaterials );
}
} else {
if ( isLine ) {
mesh = new THREE.LineSegments( buffergeometry, createdMaterials[ 0 ] );
} else if ( isPoints ) {
mesh = new THREE.Points( buffergeometry, createdMaterials[ 0 ] );
} else {
mesh = new THREE.Mesh( buffergeometry, createdMaterials[ 0 ] );
}
}
mesh.name = object.name;
container.add( mesh );
}
console.timeEnd( 'OBJLoader' );
return container;
}
};
return OBJLoader;
} )();
-648
View File
@@ -1,648 +0,0 @@
/**
* bambuddy_adapter.js
* Bridges OctoPrint-PrettyGCode to Bambuddy's API.
*
* Load this BEFORE prettygcode.js. It provides:
* - OCTOPRINT_VIEWMODELS shim
* - Minimal KnockoutJS observable shim (ko.observable)
* - fetch() + XHR interceptors for path rewriting
* - Bambuddy WebSocket → fromCurrentData bridge
* - File picker backed by Bambuddy's library API
* - Settings load/save via plugin settings endpoint
*
* What works:
* - Full 3D GCode visualisation
* - Dark mode and all dat.GUI settings
* - File selection from Bambuddy's file library
* - Print progress highlight (% based)
* - Auto-load currently printing file
*
* What doesn't work (Bambu hardware limitation):
* - Live nozzle animation during printing — Bambu printers do not expose
* GCode serial echo logs (Send: G1 X...), so PrintHeadSimulator has no input.
*/
(function () {
'use strict';
const API_BASE = '/api/v1';
const VIEWER_BASE = '/gcode-viewer'; // static assets now served from here
// -------------------------------------------------------------------------
// Auth helper
// -------------------------------------------------------------------------
function authHeaders() {
// sessionStorage is used when the user opts out of "remember me";
// fall back to localStorage for persistent sessions.
const token = sessionStorage.getItem('auth_token') ?? localStorage.getItem('auth_token');
return token ? { Authorization: 'Bearer ' + token } : {};
}
// When auth is enabled and the user has no valid token, every API call
// returns 401 and the viewer chrome stays on screen showing empty state.
// Intercept the first 401 and hand control back to the SPA, which owns
// the login flow and will redirect to /login when appropriate.
let _authRedirectFired = false;
function apiFetch(path, opts) {
return fetch(API_BASE + path, {
...opts,
headers: { ...authHeaders(), ...(opts && opts.headers) },
cache: 'no-store',
}).then((response) => {
if (response.status === 401 && !_authRedirectFired) {
_authRedirectFired = true;
try {
sessionStorage.removeItem('auth_token');
localStorage.removeItem('auth_token');
} catch (e) { /* storage unavailable */ }
window.top.location.replace('/');
}
return response;
});
}
// -------------------------------------------------------------------------
// 1. Minimal KnockoutJS shim (ko.observable / ko.computed)
// -------------------------------------------------------------------------
window.ko = {
observable: function (initial) {
var _val = initial;
var _subs = [];
var obs = function (newVal) {
if (arguments.length > 0) {
_val = newVal;
_subs.forEach(function (cb) { try { cb(newVal); } catch (e) {} });
}
return _val;
};
obs.subscribe = function (cb) {
_subs.push(cb);
return { dispose: function () { _subs = _subs.filter(function (s) { return s !== cb; }); } };
};
obs.peek = function () { return _val; };
return obs;
},
computed: function (fn) {
var obs = window.ko.observable(null);
try { obs(fn()); } catch (e) {}
return obs;
},
pureComputed: function (fn) { return window.ko.computed(fn); },
mapping: { fromJS: function (obj) { return obj; } },
};
// -------------------------------------------------------------------------
// 2. OCTOPRINT_VIEWMODELS registration shim
// -------------------------------------------------------------------------
window.OCTOPRINT_VIEWMODELS = [];
// -------------------------------------------------------------------------
// 3. Fake OctoPrint settings / printer profile / login viewmodels
// -------------------------------------------------------------------------
var fakeSettings = {
webcam: {
streamUrl: ko.observable(''),
flipH: ko.observable(false),
flipV: ko.observable(false),
rotate90: ko.observable(false),
},
plugins: {
prettygcode: {
darkMode: ko.observable(false),
},
},
};
// Bed sizes for common Bambu models (mm)
// Fallback bed size used until loadArchiveById() fetches the archive's
// actual build_volume from /api/v1/archives/{id}/capabilities.
var DEFAULT_BED = { width: 256, depth: 256, height: 256 };
var currentBed = Object.assign({}, DEFAULT_BED);
function makeFakeProfileData(bed) {
return {
volume: {
width: ko.observable(bed.width),
depth: ko.observable(bed.depth),
height: ko.observable(bed.height),
origin: ko.observable('lowerleft'),
formFactor: ko.observable('rectangular'),
// Make custom_box a function so prettygcode.js uses width()/depth()/height()
custom_box: function () { return false; },
},
};
}
var fakePrinterProfiles = {
currentProfileData: ko.observable(makeFakeProfileData(currentBed)),
};
var fakeLoginState = {
isUser: ko.observable(true),
isAdmin: ko.observable(false),
};
var fakeControl = {};
// -------------------------------------------------------------------------
// 4. fetch() interceptor — rewrite OctoPrint paths to Bambuddy
// -------------------------------------------------------------------------
var _originalFetch = window.fetch.bind(window);
window.fetch = function (resource, init) {
var url = (typeof resource === 'string') ? resource
: (resource && resource.url) ? resource.url
: null;
if (url) {
// Normalize: strip scheme+host so regexes work on the path regardless
// of whether the browser resolved a relative URL to absolute.
var path = url.replace(/^https?:\/\/[^\/]+/, '');
// Also strip the viewer's own path prefix — the browser resolves relative URLs
// like 'downloads/files/local/...' to '/gcode-viewer/downloads/...' because
// the page is served from /gcode-viewer/. The regexes below expect bare paths.
path = path.replace(/^\/gcode-viewer(?=\/|$)/, '');
var newPath = path;
// OctoPrint file download → Bambuddy library download
newPath = newPath.replace(
/^\/?downloads\/files\/local\/__bambuddy_file_(\d+)$/,
API_BASE + '/library/files/$1/download'
);
// OctoPrint file download → Bambuddy archive gcode (specific plate)
newPath = newPath.replace(
/^\/?downloads\/files\/local\/__bambuddy_archive_(\d+)_plate(\d+)$/,
API_BASE + '/archives/$1/gcode?plate=$2'
);
// OctoPrint file download → Bambuddy archive gcode (first plate)
newPath = newPath.replace(
/^\/?downloads\/files\/local\/__bambuddy_archive_(\d+)$/,
API_BASE + '/archives/$1/gcode'
);
// OctoPrint file download → Bambuddy library file gcode
// (sliced LibraryFile — extracts embedded gcode from .gcode.3mf
// or returns plain .gcode). Plate is ignored upstream for now.
newPath = newPath.replace(
/^\/?downloads\/files\/local\/__bambuddy_libgcode_(\d+)(?:_plate\d+)?$/,
API_BASE + '/library/files/$1/gcode'
);
// OctoPrint plugin static assets → gcode-viewer static files
newPath = newPath.replace(
/^\/?plugin\/prettygcode\/static\//,
VIEWER_BASE + '/'
);
if (newPath !== path) {
url = newPath;
resource = url; // always pass as string after rewriting
}
// Inject auth header for all Bambuddy API calls
if (url.startsWith(API_BASE)) {
var hdrs = authHeaders();
init = init || {};
init.headers = Object.assign({}, hdrs, init.headers || {});
}
}
var promise = _originalFetch(resource, init);
// Tee GCode downloads to build the layer map for sync + nozzle animation
if (url && (url.match(/\/library\/files\/\d+\/download/) || url.match(/\/archives\/\d+\/gcode/))) {
promise = promise.then(function (response) {
var clone = response.clone();
clone.text().then(function (text) {
gcodeLayerMap = parseGcodeLayerMap(text);
lastFedLayer = -1;
console.log('[PrettyGCode] Parsed ' + gcodeLayerMap.layerOffsets.length +
' layers for sync (' + Math.round(gcodeLayerMap.totalBytes / 1024) + ' KB)');
}).catch(function (e) {
console.warn('[PrettyGCode] GCode layer parse failed:', e);
});
return response;
});
}
return promise;
};
// -------------------------------------------------------------------------
// 5. XHR interceptor — rewrite OctoPrint paths (used by THREE.OBJLoader etc.)
// -------------------------------------------------------------------------
var _origXHROpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (method, url) {
if (typeof url === 'string') {
// Strip host if absolute, then rewrite OctoPrint static asset paths
var path = url.replace(/^https?:\/\/[^\/]+/, '');
path = path.replace(/^\/?plugin\/prettygcode\/static\//, VIEWER_BASE + '/');
url = path;
}
var args = Array.prototype.slice.call(arguments);
args[1] = url;
return _origXHROpen.apply(this, args);
};
// -------------------------------------------------------------------------
// 6. GCode layer parser
//
// Builds a layer map from the raw GCode text so we can:
// a) Map layer_num → byte offset in file (drives prettygcode's filepos sync
// and layer highlight, same as if OctoPrint were reporting filepos)
// b) Extract a set of G0/G1 commands per layer to feed the PrintHeadSimulator
// as synthetic "Send: G1 X... Y... Z..." entries, animating the nozzle model.
//
// Layer detection mirrors prettygcode.js: a new layer starts on the first extrusion
// at a Z position we haven't extruded at before.
// -------------------------------------------------------------------------
function parseGcodeLayerMap(text) {
var lines = text.split('\n');
var layerOffsets = []; // layerOffsets[i] = byte pos in file where layer i starts
var layerCmds = []; // layerCmds[i] = array of ' G1 X... Y... Z...' strings
var byteOffset = 0;
var x = 0, y = 0, z = 0, e = 0;
var relative = false, relativeE = false;
var currentLayerZ = null;
var curCmds = [];
for (var i = 0; i < lines.length; i++) {
var raw = lines[i];
// +1 for the \n that was consumed by split
var lineBytes = raw.length + 1;
var cmd = raw.replace(/;.*$/, '').trim();
if (!cmd) { byteOffset += lineBytes; continue; }
var parts = cmd.split(/\s+/);
var g = parts[0].toUpperCase();
if (g === 'G90') { relative = false; relativeE = false; }
else if (g === 'G91') { relative = true; relativeE = true; }
else if (g === 'M82') { relativeE = false; }
else if (g === 'M83') { relativeE = true; }
else if (g === 'G92') {
// coordinate reset
for (var p = 1; p < parts.length; p++) {
var k0 = parts[p][0].toUpperCase();
var v0 = parseFloat(parts[p].slice(1));
if (!isNaN(v0)) {
if (k0 === 'X') x = v0;
else if (k0 === 'Y') y = v0;
else if (k0 === 'Z') z = v0;
else if (k0 === 'E') e = v0;
}
}
} else if (g === 'G0' || g === 'G1') {
var nx = x, ny = y, nz = z, ne = e;
var hasE = false;
for (var p = 1; p < parts.length; p++) {
if (!parts[p]) continue;
var k1 = parts[p][0].toUpperCase();
var v1 = parseFloat(parts[p].slice(1));
if (isNaN(v1)) continue;
if (k1 === 'X') nx = relative ? x + v1 : v1;
else if (k1 === 'Y') ny = relative ? y + v1 : v1;
else if (k1 === 'Z') nz = relative ? z + v1 : v1;
else if (k1 === 'E') { ne = relativeE ? e + v1 : v1; hasE = true; }
}
// New layer: first extrusion at a new Z (same logic as prettygcode.js)
if (hasE && ne > e && nz !== currentLayerZ) {
currentLayerZ = nz;
if (curCmds.length > 0) layerCmds.push(curCmds);
else if (layerOffsets.length > 0) layerCmds.push([]); // gap layer
curCmds = [];
layerOffsets.push(byteOffset);
}
// Record movement commands for nozzle sim (keep arrays small — max 500/layer)
if ((hasE || nz !== z) && curCmds.length < 500) {
curCmds.push(' G1 X' + nx.toFixed(3) +
' Y' + ny.toFixed(3) +
' Z' + nz.toFixed(3));
}
x = nx; y = ny; z = nz; e = ne;
}
byteOffset += lineBytes;
}
if (curCmds.length > 0) layerCmds.push(curCmds);
return {
layerOffsets: layerOffsets,
layerCmds: layerCmds,
totalBytes: byteOffset,
};
}
// -------------------------------------------------------------------------
// 8. State
// -------------------------------------------------------------------------
var viewModel = null;
var currentFileId = null;
var currentFilename = null;
var currentFileDate = 0; // stable epoch — only changes when a new file is loaded
var gcodeLayerMap = null; // parsed layer data: {layerOffsets, layerCmds, totalBytes}
var lastFedLayer = -1; // last layer_num whose commands we fed to printHeadSim
// The viewer is scoped to previewing a specific archive (/gcode-viewer?archive=<id>).
// It no longer observes live printer state, so the WebSocket connection, the
// printer selector, auto-load-currently-printing, and library file picker are all
// intentionally absent. Bed size is derived from the archive's sliced_for_model.
function updateFilenameDisplay(filename) {
var el = document.getElementById('bb-current-file');
if (el) el.textContent = filename || '— no file loaded —';
}
// -------------------------------------------------------------------------
// 10. Archive loader — invoked via /gcode-viewer/?archive=<id>
// -------------------------------------------------------------------------
function loadArchiveById(archiveId, plate) {
// Pretygcode downloads /downloads/files/local/__bambuddy_archive_<id>(_plate<N>)
// and the fetch intercept rewrites it to /api/v1/archives/<id>/gcode[?plate=N].
var plateSuffix = (typeof plate === 'number' && plate >= 1) ? ('_plate' + plate) : '';
currentFileId = 'archive_' + archiveId + plateSuffix;
currentFilename = 'Archive #' + archiveId + (plateSuffix ? (' (plate ' + plate + ')') : '');
currentFileDate = Date.now();
gcodeLayerMap = null;
lastFedLayer = -1;
stopPlayback(true);
updateFilenameDisplay(currentFilename);
var playBtn = document.getElementById('bb-play-btn');
if (playBtn) playBtn.disabled = false;
if (viewModel && viewModel.fromCurrentData) {
viewModel.fromCurrentData({
job: {
file: {
path: '__bambuddy_archive_' + archiveId + plateSuffix,
date: currentFileDate,
},
estimatedPrintTime: null,
},
state: { text: 'Operational', flags: { printing: false } },
progress: { filepos: null, completion: 0 },
currentZ: null,
logs: [],
});
}
// Fetch metadata (for the filename display) and capabilities (for the
// bed size) in parallel. Capabilities extracts the actual build_volume
// from the 3MF's slicer config (printable_area / printable_height), so
// the bed matches whatever hardware the archive was sliced for — no
// hardcoded per-model map, correct for H2D (350×320×325), H-family
// machines, and any future model.
apiFetch('/archives/' + archiveId, {})
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (meta) {
if (meta && (meta.print_name || meta.filename)) {
currentFilename = (meta.print_name || meta.filename) +
(plateSuffix ? (' (plate ' + plate + ')') : '');
updateFilenameDisplay(currentFilename);
}
})
.catch(function () { /* best-effort — filename stays "Archive #N" */ });
apiFetch('/archives/' + archiveId + '/capabilities', {})
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (caps) {
if (!caps || !caps.build_volume) return;
var bv = caps.build_volume;
if (bv.x > 0 && bv.y > 0 && bv.z > 0) {
currentBed = { width: bv.x, depth: bv.y, height: bv.z };
fakePrinterProfiles.currentProfileData(makeFakeProfileData(currentBed));
}
})
.catch(function () { /* best-effort — default bed stays */ });
}
// -------------------------------------------------------------------------
// 10b. Library file loader — invoked via /gcode-viewer/?library_file=<id>
// -------------------------------------------------------------------------
function loadLibraryFileById(fileId, plate) {
// Mirror loadArchiveById, but use the library gcode endpoint. The
// /library/files/<id>/gcode endpoint extracts the embedded gcode
// from a .gcode.3mf or returns a plain .gcode body. It does not
// accept a plate selector today — multi-plate library files would
// need an upstream change. The plate suffix here is preserved in
// currentFilename for display only.
var plateSuffix = (typeof plate === 'number' && plate >= 1) ? ('_plate' + plate) : '';
currentFileId = 'libfile_' + fileId + plateSuffix;
currentFilename = 'Library file #' + fileId + (plateSuffix ? (' (plate ' + plate + ')') : '');
currentFileDate = Date.now();
gcodeLayerMap = null;
lastFedLayer = -1;
stopPlayback(true);
updateFilenameDisplay(currentFilename);
var playBtn = document.getElementById('bb-play-btn');
if (playBtn) playBtn.disabled = false;
if (viewModel && viewModel.fromCurrentData) {
viewModel.fromCurrentData({
job: {
file: {
path: '__bambuddy_libgcode_' + fileId + plateSuffix,
date: currentFileDate,
},
estimatedPrintTime: null,
},
state: { text: 'Operational', flags: { printing: false } },
progress: { filepos: null, completion: 0 },
currentZ: null,
logs: [],
});
}
// Fetch metadata for the filename display. There is no
// /library/files/<id>/capabilities endpoint, so the bed stays at
// whatever the default fakePrinterProfile is set to.
apiFetch('/library/files/' + fileId, {})
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (meta) {
if (meta && (meta.print_name || meta.filename)) {
currentFilename = (meta.print_name || meta.filename) +
(plateSuffix ? (' (plate ' + plate + ')') : '');
updateFilenameDisplay(currentFilename);
}
})
.catch(function () { /* best-effort — filename stays "Library file #N" */ });
}
// -------------------------------------------------------------------------
// 11. Initialise after DOM + scripts are ready
// -------------------------------------------------------------------------
function init() {
// Find the ViewModel registration that prettygcode.js pushed
var reg = null;
for (var i = 0; i < window.OCTOPRINT_VIEWMODELS.length; i++) {
if (window.OCTOPRINT_VIEWMODELS[i].construct) {
reg = window.OCTOPRINT_VIEWMODELS[i];
break;
}
}
if (!reg) {
console.error('[PrettyGCode] No ViewModel found in OCTOPRINT_VIEWMODELS');
return;
}
try {
viewModel = new reg.construct([
fakeSettings,
fakeLoginState,
fakePrinterProfiles,
fakeControl,
]);
} catch (e) {
console.error('[PrettyGCode] ViewModel constructor failed:', e);
return;
}
if (viewModel.onAfterBinding) {
try { viewModel.onAfterBinding(); } catch (e) {}
}
// Trigger tab activation — this calls onTabChange which initialises the Three.js scene
if (viewModel.onTabChange) {
try { viewModel.onTabChange('#tab_plugin_prettygcode', ''); } catch (e) {
console.error('[PrettyGCode] onTabChange failed:', e);
}
}
console.log('[PrettyGCode] Bambuddy adapter initialised');
// Wire up playback controls
var playBtn = document.getElementById('bb-play-btn');
var speedSel = document.getElementById('bb-play-speed');
if (playBtn) {
playBtn.addEventListener('click', function () {
if (isPlaying) stopPlayback();
else startPlayback();
});
}
if (speedSel) {
speedSel.addEventListener('change', function () {
layersPerTick = parseInt(speedSel.value, 10) || 1;
// Restart if already playing so speed takes effect immediately
if (isPlaying) { stopPlayback(); startPlayback(); }
});
}
}
// -------------------------------------------------------------------------
// 14. Playback engine
// -------------------------------------------------------------------------
var isPlaying = false;
var playInterval = null;
var layersPerTick = 1; // layers advanced per 50 ms tick
var TICK_MS = 50; // ~20 fps
function getSlider() { return $('#myslider-vertical'); }
function startPlayback() {
var $sl = getSlider();
if (!$sl.length) return;
var data = $sl.data('_pgslider');
if (!data) return;
var max = data.opts.max || 0;
if (max === 0) return;
// Restart from beginning if already at the end
var cur = data.opts.value || 0;
if (cur >= max) cur = 0;
// Suppress live-print sync while playing
var evStart = $.Event('slideStart'); evStart.value = cur; $sl.trigger(evStart);
_setSliderLayer($sl, cur);
isPlaying = true;
_updatePlayBtn();
playInterval = setInterval(function () {
var d = getSlider().data('_pgslider');
if (!d) { stopPlayback(); return; }
var next = (d.opts.value || 0) + layersPerTick;
if (next >= d.opts.max) {
next = d.opts.max;
_setSliderLayer(getSlider(), next);
stopPlayback(/* skipEvStop */ false);
return;
}
_setSliderLayer(getSlider(), next);
}, TICK_MS);
}
function stopPlayback(skipEvStop) {
if (playInterval) { clearInterval(playInterval); playInterval = null; }
isPlaying = false;
_updatePlayBtn();
if (!skipEvStop) {
var $sl = getSlider();
if ($sl.length) {
var d = $sl.data('_pgslider');
var evStop = $.Event('slideStop');
evStop.value = d ? d.opts.value : 0;
$sl.trigger(evStop);
}
}
}
function _setSliderLayer($sl, layer) {
$sl.slider('setValue', layer);
var ev = $.Event('slide'); ev.value = layer; $sl.trigger(ev);
$sl.find('.slider-handle').text(layer);
}
function _updatePlayBtn() {
var btn = document.getElementById('bb-play-btn');
if (btn) btn.textContent = isPlaying ? '⏸' : '▶';
}
// Run after all scripts have loaded. init() (viewmodel + 3D canvas) runs
// 200 ms later to let prettygcode.js finish its own synchronous setup first.
function onDomReady() {
setTimeout(function () {
init();
// If the viewer was opened with ?archive=<id>[&plate=<N>] or
// ?library_file=<id>[&plate=<N>], load that source's gcode once
// the viewmodel is ready.
try {
var params = new URLSearchParams(window.location.search);
var archiveParam = params.get('archive');
var libParam = params.get('library_file');
var plateParam = params.get('plate');
var plateId = (plateParam && /^[1-9][0-9]*$/.test(plateParam))
? parseInt(plateParam, 10)
: undefined;
if (archiveParam && /^[1-9][0-9]*$/.test(archiveParam)) {
var archiveId = parseInt(archiveParam, 10);
setTimeout(function () { loadArchiveById(archiveId, plateId); }, 50);
} else if (libParam && /^[1-9][0-9]*$/.test(libParam)) {
var libId = parseInt(libParam, 10);
setTimeout(function () { loadLibraryFileById(libId, plateId); }, 50);
}
} catch (e) { /* URLSearchParams unsupported — skip */ }
}, 200);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', onDomReady);
} else {
onDomReady();
}
// -------------------------------------------------------------------------
// Public API
// -------------------------------------------------------------------------
window.BambuddyPrettyGCode = {
loadArchive: loadArchiveById,
loadLibraryFile: loadLibraryFileById,
getViewModel: function () { return viewModel; },
play: startPlayback,
stop: stopPlayback,
};
})();
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-2
View File
File diff suppressed because one or more lines are too long
@@ -1,8 +0,0 @@
# Exported with QuadFace Tools (0.14.0)
# SketchUp 17.2.2555
# Model name: ExtruderNozzle
newmtl FrontColor
Ka 0.000000 0.000000 0.000000
Kd 1.000000 1.000000 1.000000
Ks 0.330000 0.330000 0.330000
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-151
View File
@@ -1,151 +0,0 @@
/**
* slider-shim.js
* Minimal jQuery plugin shim for the bootstrap-slider API used by prettygcode.js.
*
* Supports the subset used by PrettyGCode:
* $(el).slider(opts) — init
* $(el).slider("setValue", v) — set value
* $(el).slider("setMax", v) — update max
* $(el).on("slide", fn) — fires with event.value
* $(el).on("slideStart", fn)
* $(el).on("slideStop", fn)
*/
(function ($) {
'use strict';
$.fn.slider = function (optsOrCmd, cmdArg1, cmdArg2, cmdArg3) {
return this.each(function () {
var $el = $(this);
var data = $el.data('_pgslider');
// ---------- init ----------
if (!data || typeof optsOrCmd === 'object') {
var opts = $.extend({
id: null,
orientation: 'horizontal',
reversed: false,
min: 0,
max: 100,
value: 0,
}, typeof optsOrCmd === 'object' ? optsOrCmd : {});
// Build the DOM. opts.id is HTML-attribute-escaped before
// interpolation so a future caller passing a tainted id can't
// break out of the attribute (CodeQL: js/html-constructed-from-input).
function escapeAttr(s) {
return String(s).replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
var isVertical = opts.orientation === 'vertical';
var trackHtml =
'<div class="slider' + (isVertical ? ' slider-vertical' : '') + '"' +
(opts.id ? ' id="' + escapeAttr(opts.id) + '"' : '') + '>' +
'<div class="slider-track"><div class="slider-selection"></div></div>' +
'<div class="slider-handle round">0</div>' +
'</div>';
$el.html(trackHtml);
var $slider = $el.find('.slider');
var $handle = $el.find('.slider-handle');
var $selection = $el.find('.slider-selection');
var isDragging = false;
data = {
opts: opts,
$slider: $slider,
$handle: $handle,
$selection: $selection,
};
$el.data('_pgslider', data);
function pct(v) {
var range = data.opts.max - data.opts.min;
if (range === 0) return 0;
var p = (v - data.opts.min) / range * 100;
return opts.reversed ? 100 - p : p;
}
function updateUI(val) {
var p = pct(val);
$handle.text(val);
if (isVertical) {
$handle.css({ top: p + '%', bottom: '' });
$selection.css({ height: (100 - p) + '%', top: p + '%' });
} else {
$handle.css({ left: p + '%' });
$selection.css({ width: p + '%' });
}
}
data.updateUI = updateUI;
updateUI(opts.value);
data.opts.value = opts.value;
// Mouse interaction
function getValueFromEvent(e) {
var offset = $slider.offset();
var range = data.opts.max - data.opts.min;
var p;
if (isVertical) {
var h = $slider.height();
p = (e.pageY - offset.top) / h;
} else {
var w = $slider.width();
p = (e.pageX - offset.left) / w;
}
p = Math.max(0, Math.min(1, p));
if (opts.reversed) p = 1 - p;
return Math.round(data.opts.min + p * range);
}
$slider.on('mousedown', function (e) {
isDragging = true;
var val = getValueFromEvent(e);
data.opts.value = val;
updateUI(val);
var ev = $.Event('slideStart'); ev.value = val;
$el.trigger(ev);
e.preventDefault();
});
$(document).on('mousemove.pgslider_' + $el.attr('id'), function (e) {
if (!isDragging) return;
var val = getValueFromEvent(e);
data.opts.value = val;
updateUI(val);
var ev = $.Event('slide'); ev.value = val;
$el.trigger(ev);
});
$(document).on('mouseup.pgslider_' + $el.attr('id'), function (e) {
if (!isDragging) return;
isDragging = false;
var val = getValueFromEvent(e);
data.opts.value = val;
updateUI(val);
var ev = $.Event('slideStop'); ev.value = val;
$el.trigger(ev);
});
return;
}
// ---------- commands ----------
if (optsOrCmd === 'setValue') {
data.opts.value = cmdArg1;
data.updateUI(cmdArg1);
// prettygcode.js calls slider('setValue', N, false, true) after loading
// — the third arg means "trigger the slide event so listeners update state"
if (cmdArg3) {
var ev = $.Event('slide'); ev.value = cmdArg1; $el.trigger(ev);
}
} else if (optsOrCmd === 'setMax') {
data.opts.max = cmdArg1;
data.updateUI(data.opts.value);
}
});
};
}(jQuery));
-996
View File
File diff suppressed because one or more lines are too long
-13
View File
@@ -259,19 +259,6 @@ def stage_backend(frontend_dist: Path) -> None:
ignore=shutil.ignore_patterns(".DS_Store", "._*"),
)
# gcode_viewer/ is a vendored 3D-preview iframe served via explicit
# routes in main.py (looked up via static_dir.parent / "gcode_viewer").
# In the staged layout STAGING/app/static/'s sibling is STAGING/app/,
# so place the directory next to static/ to match runtime resolution.
gcode_viewer_src = REPO_ROOT / "gcode_viewer"
if gcode_viewer_src.exists():
log("staging gcode_viewer/")
shutil.copytree(
gcode_viewer_src,
app / "gcode_viewer",
ignore=shutil.ignore_patterns(".DS_Store", "._*"),
)
def stage_nssm() -> None:
target = STAGING / "bin"
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -26,8 +26,8 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-CRGw6Y1U.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D4VkH83v.css">
<script type="module" crossorigin src="/assets/index-Bh7umAlT.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-jOkuIvep.css">
</head>
<body>
<div id="root"></div>