feat: add embedded GCode viewer (#963)

* feat: add embedded GCode viewer

Adds PrettyGCode as a built-in GCode visualiser embedded directly in the
Bambuddy layout, so users can preview and inspect GCode files without
leaving the dashboard.
This commit is contained in:
Nathen Fredrick 2026-04-22 10:14:42 +01:00 committed by GitHub
parent 5215ac68e9
commit 3adce435ee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 15895 additions and 67 deletions

View file

@ -104,11 +104,20 @@ def to_absolute_path(relative_path: str | None) -> Path | None:
"""Convert a relative path (from database) to an absolute path for file operations."""
if not relative_path:
return None
# Handle already-absolute paths (for backwards compatibility during migration)
path = Path(relative_path)
# Handle already-absolute paths verbatim (backwards compatibility during migration).
# Legacy DB rows may store absolute paths that predate the base_dir layout; the
# traversal guard below only applies to relative paths coming from user input.
if path.is_absolute():
return path
return Path(app_settings.base_dir) / relative_path
return path.resolve()
base = Path(app_settings.base_dir).resolve()
resolved = (base / relative_path).resolve()
# Guard against path traversal — resolved path must stay inside base_dir.
# Use is_relative_to() to avoid the /data/app vs /data/app_evil prefix confusion
# that a plain startswith(str(base)) check would miss.
if not resolved.is_relative_to(base):
raise ValueError(f"Path escapes base directory: {relative_path!r}")
return resolved
def calculate_file_hash(file_path: Path) -> str:

View file

@ -1,5 +1,6 @@
import asyncio
import logging
import mimetypes as _mimetypes
import posixpath
import time
from contextlib import asynccontextmanager
@ -4331,19 +4332,37 @@ 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.
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self'; "
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
"img-src 'self' data: blob:; "
"media-src 'self' blob:; "
"connect-src 'self' ws: wss:; "
"font-src 'self' data: https://fonts.gstatic.com; "
"object-src 'none'; "
"base-uri 'self'; "
"frame-src 'self' http: https:; "
"frame-ancestors 'none';"
)
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' https://fonts.googleapis.com; "
"img-src 'self' data: blob:; "
"media-src 'self' blob:; "
"connect-src 'self' ws: wss:; "
"font-src 'self' data: https://fonts.gstatic.com; "
"object-src 'none'; "
"base-uri 'self'; "
"frame-src 'self' http: https:; "
"frame-ancestors 'self';"
)
else:
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self'; "
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
"img-src 'self' data: blob:; "
"media-src 'self' blob:; "
"connect-src 'self' ws: wss:; "
"font-src 'self' data: https://fonts.gstatic.com; "
"object-src 'none'; "
"base-uri 'self'; "
"frame-src 'self' http: https:; "
"frame-ancestors 'none';"
)
if request.url.scheme == "https":
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
return response
@ -4591,6 +4610,35 @@ async def serve_sw_register():
return {"error": "sw-register.js not found"}
# ── 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()
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")
@app.get("/gcode-viewer/")
async def serve_gcode_viewer_index() -> FileResponse:
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)
@app.get("/{full_path:path}")
async def serve_spa(full_path: str):

View file

@ -0,0 +1,85 @@
"""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.
"""
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_no_trailing_slash_redirects_or_responds(
self, async_client: AsyncClient
):
"""GET /gcode-viewer (no trailing slash) is handled by the explicit route."""
response = await async_client.get("/gcode-viewer", follow_redirects=True)
assert response.status_code in (200, 404)
assert b'<div id="root">' 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

View file

@ -0,0 +1,115 @@
"""Tests for to_absolute_path() path-traversal guard in library routes.
Covers three behaviours added/changed by the GCode viewer PR:
1. Relative paths that escape base_dir are rejected with ValueError.
2. Path.is_relative_to() is used instead of startswith(str(base)),
avoiding the /data/app vs /data/app_evil prefix-confusion bug.
3. Legacy absolute paths (pre-migration DB rows) are returned verbatim
instead of raising ValueError.
"""
from pathlib import Path
from unittest.mock import patch
import pytest
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _call(relative_path, base_dir):
"""Call to_absolute_path with base_dir patched to *base_dir*."""
from backend.app.api.routes.library import to_absolute_path
with patch("backend.app.api.routes.library.app_settings") as mock_settings:
mock_settings.base_dir = str(base_dir)
return to_absolute_path(relative_path)
# ---------------------------------------------------------------------------
# None / empty guard
# ---------------------------------------------------------------------------
class TestNullInputs:
def test_none_returns_none(self, tmp_path):
assert _call(None, tmp_path) is None
def test_empty_string_returns_none(self, tmp_path):
assert _call("", tmp_path) is None
# ---------------------------------------------------------------------------
# Relative path traversal guard
# ---------------------------------------------------------------------------
class TestRelativePathTraversal:
def test_normal_relative_path_resolves(self, tmp_path):
"""A safe relative path resolves to base_dir / rel."""
base = tmp_path / "data"
base.mkdir()
result = _call("files/model.gcode", base)
assert result == (base / "files" / "model.gcode").resolve()
def test_traversal_via_dotdot_raises(self, tmp_path):
"""../etc/passwd must be rejected."""
base = tmp_path / "data"
base.mkdir()
with pytest.raises(ValueError, match="escapes base directory"):
_call("../etc/passwd", base)
def test_traversal_via_nested_dotdot_raises(self, tmp_path):
"""files/../../etc/passwd must be rejected."""
base = tmp_path / "data"
base.mkdir()
with pytest.raises(ValueError, match="escapes base directory"):
_call("files/../../etc/passwd", base)
def test_prefix_confusion_is_blocked(self, tmp_path):
"""Ensure /data/app_evil/secret is not permitted when base is /data/app.
A naive startswith(str(base)) check would allow this because
'/data/app_evil'.startswith('/data/app') is True.
Path.is_relative_to() must be used instead.
"""
# Simulate: base = /tmp/.../data_app, sibling = /tmp/.../data_app_evil
base = tmp_path / "data_app"
sibling = tmp_path / "data_app_evil"
base.mkdir()
sibling.mkdir()
# Construct a relative path that resolves into the *sibling* dir.
# from base: ../data_app_evil/secret
with pytest.raises(ValueError, match="escapes base directory"):
_call("../data_app_evil/secret", base)
# ---------------------------------------------------------------------------
# Legacy absolute path pass-through
# ---------------------------------------------------------------------------
class TestLegacyAbsolutePaths:
def test_absolute_path_inside_base_is_returned(self, tmp_path):
"""An absolute path that happens to be inside base_dir is returned as-is."""
base = tmp_path / "data"
base.mkdir()
abs_path = str(base / "archive" / "old.3mf")
result = _call(abs_path, base)
assert result == Path(abs_path).resolve()
def test_absolute_path_outside_base_is_returned(self, tmp_path):
"""An absolute path outside base_dir is returned verbatim (legacy compat).
Pre-migration DB rows may store absolute paths that predate the
base_dir layout. These must NOT raise ValueError; callers are
responsible for further existence checks.
"""
base = tmp_path / "data"
base.mkdir()
outside = tmp_path / "old_archive" / "legacy.3mf"
result = _call(str(outside), base)
assert result == outside.resolve()

View file

@ -21,6 +21,7 @@ import { SystemInfoPage } from './pages/SystemInfoPage';
import { LoginPage } from './pages/LoginPage';
import { SetupPage } from './pages/SetupPage';
import { NotificationsPage } from './pages/NotificationsPage';
import { GCodeViewerPage } from './pages/GCodeViewerPage';
import { useWebSocket } from './hooks/useWebSocket';
import { useStreamTokenSync } from './hooks/useCameraStreamToken';
import { ThemeProvider } from './contexts/ThemeContext';
@ -195,6 +196,7 @@ function App() {
<Route path="groups" element={<Navigate to="/settings?tab=users" replace />} />
<Route path="system" element={<SystemInfoPage />} />
<Route path="notifications" element={<NotificationsPage />} />
<Route path="gcode-viewer" element={<GCodeViewerPage />} />
<Route path="external/:id" element={<ExternalLinkPage />} />
</Route>
</Routes>

View file

@ -1,6 +1,6 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, Bell, type LucideIcon } from 'lucide-react';
import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, Bell, Layers, type LucideIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useTheme } from '../contexts/ThemeContext';
import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
@ -36,6 +36,7 @@ export const defaultNavItems: NavItem[] = [
{ id: 'projects', to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' },
{ id: 'inventory', to: '/inventory', icon: Disc3, labelKey: 'nav.inventory' },
{ id: 'files', to: '/files', icon: FolderOpen, labelKey: 'nav.files' },
{ id: 'gcode-viewer', to: '/gcode-viewer', icon: Layers, labelKey: 'nav.gcodeViewer' },
// User-account features: kept adjacent to Settings intentionally
{ id: 'notifications', to: '/notifications', icon: Bell, labelKey: 'nav.notifications' },
{ id: 'settings', to: '/settings', icon: Settings, labelKey: 'nav.settings' },

View file

@ -10,6 +10,7 @@ export default {
projects: 'Projekte',
inventory: 'Filament',
files: 'Dateimanager',
gcodeViewer: 'GCode-Viewer',
notifications: 'Benachrichtigungen',
settings: 'Einstellungen',
system: 'System',
@ -213,6 +214,7 @@ export default {
chamberLightOff: 'Kammerbeleuchtung ausschalten',
// Files
files: 'Dateien',
gcodeViewer: 'GCode-Viewer',
browseFiles: 'Druckerdateien durchsuchen',
// Smart plug
autoOffAfterPrint: 'Automatisches Ausschalten nach Druck',
@ -2891,6 +2893,7 @@ export default {
lowDiskSpaceWarning: 'Warnung: Wenig Speicherplatz',
lowDiskSpaceDetails: 'Nur {{free}} frei von {{total}} gesamt. Schwellenwert ist auf {{threshold}} GB eingestellt.',
files: 'Dateien',
gcodeViewer: 'GCode-Viewer',
folders: 'Ordner',
size: 'Größe',
free: 'Frei',
@ -2994,6 +2997,7 @@ export default {
createFirstButton: 'Erstes Projekt erstellen',
create: 'Erstellen',
files: 'Dateien',
gcodeViewer: 'GCode-Viewer',
prints: 'Drucke',
plates: 'Platten',
parts: 'Teile',

View file

@ -10,6 +10,7 @@ export default {
projects: 'Projects',
inventory: 'Filament',
files: 'File Manager',
gcodeViewer: 'GCode Viewer',
notifications: 'Notifications',
settings: 'Settings',
system: 'System',
@ -213,6 +214,7 @@ export default {
chamberLightOff: 'Turn off chamber light',
// Files
files: 'Files',
gcodeViewer: 'GCode Viewer',
browseFiles: 'Browse printer files',
// Smart plug
autoOffAfterPrint: 'Auto power-off after print',
@ -2894,6 +2896,7 @@ export default {
lowDiskSpaceWarning: 'Low disk space warning',
lowDiskSpaceDetails: 'Only {{free}} free of {{total}} total. Threshold is set to {{threshold}} GB in settings.',
files: 'Files',
gcodeViewer: 'GCode Viewer',
folders: 'Folders',
size: 'Size',
free: 'Free',
@ -2997,6 +3000,7 @@ export default {
createFirstButton: 'Create Your First Project',
create: 'Create',
files: 'Files',
gcodeViewer: 'GCode Viewer',
prints: 'Prints',
plates: 'plates',
parts: 'parts',

View file

@ -10,6 +10,7 @@ export default {
projects: 'Projets',
inventory: 'Filament',
files: 'Gestionnaire de fichiers',
gcodeViewer: 'Visionneuse GCode',
notifications: 'Notifications',
settings: 'Paramètres',
system: 'Système',
@ -213,6 +214,7 @@ export default {
chamberLightOff: 'Éteindre la lumière de la chambre',
// Files
files: 'Fichiers',
gcodeViewer: 'Visionneuse GCode',
browseFiles: 'Parcourir les fichiers de l\'imprimante',
// Smart plug
autoOffAfterPrint: 'Extinction auto après impression',
@ -2813,6 +2815,7 @@ export default {
lowDiskSpaceWarning: 'Espace disque faible',
lowDiskSpaceDetails: '{{free}} libre sur {{total}}. Seuil : {{threshold}} Go.',
files: 'Fichiers',
gcodeViewer: 'Visionneuse GCode',
folders: 'Dossiers',
size: 'Taille',
free: 'Libre',
@ -2916,6 +2919,7 @@ export default {
createFirstButton: 'Créer votre premier projet',
create: 'Créer',
files: 'Fichiers',
gcodeViewer: 'Visionneuse GCode',
prints: 'Impressions',
plates: 'plateaux',
parts: 'pièces',

View file

@ -10,6 +10,7 @@ export default {
projects: 'Progetti',
inventory: 'Filamento',
files: 'File',
gcodeViewer: 'Visualizzatore GCode',
notifications: 'Notifiche',
settings: 'Impostazioni',
system: 'Sistema',
@ -213,6 +214,7 @@ export default {
chamberLightOff: 'Spegni luce camera',
// Files
files: 'File',
gcodeViewer: 'Visualizzatore GCode',
browseFiles: 'Sfoglia file stampante',
// Smart plug
autoOffAfterPrint: 'Spegnimento automatico dopo stampa',
@ -2812,6 +2814,7 @@ export default {
lowDiskSpaceWarning: 'Avviso spazio disco basso',
lowDiskSpaceDetails: 'Solo {{free}} liberi su {{total}} totali. La soglia e {{threshold}} GB nelle impostazioni.',
files: 'File',
gcodeViewer: 'Visualizzatore GCode',
folders: 'Cartelle',
size: 'Dimensione',
free: 'Libero',
@ -2915,6 +2918,7 @@ export default {
createFirstButton: 'Crea il tuo primo progetto',
create: 'Crea',
files: 'File',
gcodeViewer: 'Visualizzatore GCode',
prints: 'Stampe',
plates: 'piatti',
parts: 'parti',

View file

@ -10,6 +10,7 @@ export default {
projects: 'プロジェクト',
inventory: 'フィラメント',
files: 'ファイル管理',
gcodeViewer: 'GCodeビューア',
notifications: '通知',
settings: '設定',
system: 'システム',
@ -212,6 +213,7 @@ export default {
chamberLightOff: 'チャンバーライトをオフにしました',
// Files
files: 'ファイル',
gcodeViewer: 'GCodeビューア',
browseFiles: 'プリンターのファイルを参照',
// Smart plug
autoOffAfterPrint: '印刷後に自動電源オフ',
@ -2851,6 +2853,7 @@ export default {
lowDiskSpaceWarning: 'ディスク容量不足の警告',
lowDiskSpaceDetails: '{{total}}中{{free}}の空き容量のみ。しきい値は設定で{{threshold}}GBに設定されています。',
files: 'ファイル',
gcodeViewer: 'GCodeビューア',
folders: 'フォルダ',
size: 'サイズ',
free: '空き:',
@ -2954,6 +2957,7 @@ export default {
createFirstButton: '最初のプロジェクトを作成',
create: '作成',
files: 'ファイル',
gcodeViewer: 'GCodeビューア',
prints: '印刷',
plates: 'プレート',
parts: 'パーツ',

View file

@ -10,6 +10,7 @@ export default {
projects: 'Projetos',
inventory: 'Inventário',
files: 'Gerenciador de Arquivos',
gcodeViewer: 'Visualizador GCode',
notifications: 'Notificações',
settings: 'Configurações',
system: 'Sistema',
@ -213,6 +214,7 @@ export default {
chamberLightOff: 'Desligar luz da câmara',
// Files
files: 'Arquivos',
gcodeViewer: 'Visualizador GCode',
browseFiles: 'Procurar arquivos da impressora',
// Smart plug
autoOffAfterPrint: 'Desligamento automático após impressão',
@ -2826,6 +2828,7 @@ export default {
lowDiskSpaceWarning: 'Aviso de pouco espaço em disco',
lowDiskSpaceDetails: 'Apenas {{free}} livres de {{total}} no total. O limite está definido para {{threshold}} GB nas configurações.',
files: 'Arquivos',
gcodeViewer: 'Visualizador GCode',
folders: 'Pastas',
size: 'Tamanho',
free: 'Livre',
@ -2929,6 +2932,7 @@ export default {
createFirstButton: 'Crie Seu Primeiro Projeto',
create: 'Criar',
files: 'Arquivos',
gcodeViewer: 'Visualizador GCode',
prints: 'Impressões',
plates: 'Placas',
parts: 'Peças',

View file

@ -10,6 +10,7 @@ export default {
projects: '项目',
inventory: '耗材',
files: '文件管理器',
gcodeViewer: 'GCode查看器',
notifications: '通知',
settings: '设置',
system: '系统',
@ -213,6 +214,7 @@ export default {
chamberLightOff: '关闭腔室灯',
// Files
files: '文件',
gcodeViewer: 'GCode查看器',
browseFiles: '浏览打印机文件',
// Smart plug
autoOffAfterPrint: '打印后自动关机',
@ -2878,6 +2880,7 @@ export default {
lowDiskSpaceWarning: '磁盘空间不足警告',
lowDiskSpaceDetails: '仅剩 {{free}}(总共 {{total}})。阈值设置为 {{threshold}} GB。',
files: '文件',
gcodeViewer: 'GCode查看器',
folders: '文件夹',
size: '大小',
free: '剩余',
@ -2981,6 +2984,7 @@ export default {
createFirstButton: '创建您的第一个项目',
create: '创建',
files: '文件',
gcodeViewer: 'GCode查看器',
prints: '打印',
plates: '板',
parts: '零件',

View file

@ -10,6 +10,7 @@ export default {
projects: '專案',
inventory: '耗材',
files: '檔案管理器',
gcodeViewer: 'GCode 檢視器',
notifications: '通知',
settings: '設定',
system: '系統',
@ -213,6 +214,7 @@ export default {
chamberLightOff: '關閉腔室燈',
// Files
files: '檔案',
gcodeViewer: 'GCode 檢視器',
browseFiles: '瀏覽印表機檔案',
// Smart plug
autoOffAfterPrint: '列印後自動關機',
@ -2878,6 +2880,7 @@ export default {
lowDiskSpaceWarning: '磁碟空間不足警告',
lowDiskSpaceDetails: '僅剩 {{free}}(總共 {{total}})。閾值設定為 {{threshold}} GB。',
files: '檔案',
gcodeViewer: 'GCode 檢視器',
folders: '資料夾',
size: '大小',
free: '剩餘',
@ -2981,6 +2984,7 @@ export default {
createFirstButton: '建立您的第一個項目',
create: '建立',
files: '檔案',
gcodeViewer: 'GCode 檢視器',
prints: '列印',
plates: '板',
parts: '零件',

View file

@ -0,0 +1,28 @@
export function GCodeViewerPage() {
// 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 (window !== window.top) {
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>
);
}
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.
<iframe
src="/gcode-viewer/"
title="GCode Viewer"
style={{
display: 'block',
width: '100%',
height: 'calc(100vh - 3.5rem)',
border: 'none',
}}
/>
);
}

View file

@ -1,13 +1,80 @@
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({
plugins: [react()],
plugins: [react(), serveGcodeViewer()],
build: {
outDir: '../static',
emptyOutDir: true,

60
gcode_viewer/VENDORED.md Normal file
View file

@ -0,0 +1,60 @@
# 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

View file

@ -0,0 +1,417 @@
/* 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);
}

264
gcode_viewer/index.html Normal file
View file

@ -0,0 +1,264 @@
<!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-toolbar label { color: #aaa; font-size: 12px; white-space: nowrap; }
#bb-printer-select {
background: #222; color: #ddd;
border: 1px solid #444; border-radius: 4px;
padding: 3px 6px; font-size: 12px;
}
#bb-file-btn {
background: #2a6496; color: #fff;
border: none; border-radius: 4px;
padding: 4px 10px; cursor: pointer; font-size: 12px;
}
#bb-file-btn:hover { background: #1c4d72; }
#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;
}
/* File picker dropdown */
#bb-file-picker {
display: none;
position: fixed; /* fixed so it's never clipped by viewer overflow */
top: 38px; left: 10px;
width: 320px;
background: #222; border: 1px solid #444;
border-radius: 6px; padding: 6px;
z-index: 9999;
box-shadow: 0 4px 16px rgba(0,0,0,0.6);
}
#bb-file-picker.bb-open { display: block; }
/* 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">
<label for="bb-printer-select">Printer:</label>
<select id="bb-printer-select"><option value="">Loading…</option></select>
<button id="bb-file-btn">&#128196; Load file</button>
<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>
<!-- File picker (positioned relative to toolbar) -->
<div id="bb-file-picker"></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
gcode_viewer/js/Line2.js Normal file
View file

@ -0,0 +1,31 @@
/**
* @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;
}
} );

View file

@ -0,0 +1,104 @@
/**
* @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;
}
} );

View file

@ -0,0 +1,391 @@
/**
* @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;
};

View file

@ -0,0 +1,65 @@
/**
* @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;
}
} );

View file

@ -0,0 +1,258 @@
/**
* @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
gcode_viewer/js/Lut.js Normal file
View file

@ -0,0 +1,185 @@
/**
* @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 ]]
};

View file

@ -0,0 +1,797 @@
/**
* @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;
} )();

View file

@ -0,0 +1,877 @@
/**
* 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)
var BAMBU_BED_SIZES = {
'X1': { width: 256, depth: 256, height: 256 },
'X1C': { width: 256, depth: 256, height: 256 },
'X1E': { width: 256, depth: 256, height: 256 },
'P1S': { width: 256, depth: 256, height: 256 },
'P1P': { width: 256, depth: 256, height: 256 },
'A1': { width: 300, depth: 300, height: 300 },
'A1 Mini': { width: 180, depth: 180, height: 180 },
};
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 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/)) {
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 ws = null;
var wsReconnectTimer = null;
var printers = []; // [{id, name, model, state, progress, subtask_name}]
var selectedPrinterId = null;
var gcodeLayerMap = null; // parsed layer data: {layerOffsets, layerCmds, totalBytes}
var lastFedLayer = -1; // last layer_num whose commands we fed to printHeadSim
// -------------------------------------------------------------------------
// 9. Bambuddy WebSocket
// -------------------------------------------------------------------------
function connectWebSocket() {
var token = localStorage.getItem('auth_token');
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
// Do NOT put the token in the URL — it would appear in server logs.
// The WebSocket endpoint is currently unauthenticated server-side;
// all sensitive calls go through authenticated fetch() instead.
var wsUrl = proto + '//' + location.host + API_BASE + '/ws';
ws = new WebSocket(wsUrl);
ws.onopen = function () {
console.log('[PrettyGCode] Connected to Bambuddy WebSocket');
};
ws.onmessage = function (event) {
try {
var msg = JSON.parse(event.data);
if (msg.type === 'printer_status') {
handlePrinterStatus(msg.printer_id, msg.data);
}
} catch (e) {}
};
ws.onclose = function () {
clearTimeout(wsReconnectTimer);
wsReconnectTimer = setTimeout(connectWebSocket, 3000);
};
ws.onerror = function () {
ws.close();
};
}
function bambuStateToOctoState(bambuState) {
var map = {
RUNNING: 'Printing',
PAUSE: 'Paused',
FAILED: 'Error',
FINISH: 'Operational',
IDLE: 'Operational',
};
return map[bambuState] || 'Operational';
}
function handlePrinterStatus(printerId, data) {
// Update printer list entry
var found = false;
for (var i = 0; i < printers.length; i++) {
if (printers[i].id === printerId) {
// Allowlist to prevent prototype pollution from crafted WS messages
var allowed2 = ['name', 'state', 'progress', 'layer_num', 'subtask_name', 'gcode_file', 'camera_url', 'model'];
allowed2.forEach(function (k) { if (k in data) printers[i][k] = data[k]; });
found = true;
break;
}
}
if (!found) {
// Only copy known, safe keys — avoids prototype pollution from a crafted WS message
var allowed = ['name', 'state', 'progress', 'layer_num', 'subtask_name', 'gcode_file', 'camera_url', 'model'];
var entry = { id: printerId };
allowed.forEach(function (k) { if (k in data) entry[k] = data[k]; });
printers.push(entry);
}
updatePrinterSelector();
// Only feed data for the selected printer
if (selectedPrinterId !== null && printerId !== selectedPrinterId) return;
if (selectedPrinterId === null && printers.length > 0) {
selectedPrinterId = printers[0].id;
}
if (!viewModel) return;
var printer = null;
for (var j = 0; j < printers.length; j++) {
if (printers[j].id === printerId) { printer = printers[j]; break; }
}
if (!printer) return;
// Update bed size from printer model
var bedKey = (printer.model || '').toUpperCase();
for (var modelName in BAMBU_BED_SIZES) {
if (bedKey.indexOf(modelName.toUpperCase()) !== -1) {
currentBed = BAMBU_BED_SIZES[modelName];
break;
}
}
// Replace the entire profile data so the subscribe() fires
fakePrinterProfiles.currentProfileData(makeFakeProfileData(currentBed));
// Auto-load currently printing file if it changed
var subtask = printer.subtask_name || printer.gcode_file || '';
if (subtask && subtask !== currentFilename) {
currentFilename = subtask;
tryAutoLoadPrintingFile(subtask);
}
// Update webcam URL
if (printer.camera_url) {
fakeSettings.webcam.streamUrl(printer.camera_url);
}
feedCurrentData(printer);
}
function feedCurrentData(printer) {
if (!viewModel || !viewModel.fromCurrentData) return;
var octoState = bambuStateToOctoState(printer.state || 'IDLE');
var isPrinting = octoState === 'Printing' || octoState === 'Paused';
// --- Layer sync via filepos -------------------------------------------
// prettygcode.js calls gcodeProxy.syncGcodeObjToFilePos(curPrintFilePos) each
// animation frame when printing + syncToProgress is on. Pass the byte offset
// of the current layer so the highlight advances correctly.
var filepos = null;
var logs = [];
if (gcodeLayerMap && isPrinting) {
// Bambu layer_num is 1-based; our layerOffsets array is 0-based.
var layerIdx = Math.max(0, (printer.layer_num || 1) - 1);
layerIdx = Math.min(layerIdx, gcodeLayerMap.layerOffsets.length - 1);
filepos = gcodeLayerMap.layerOffsets[layerIdx] || 0;
// --- Nozzle animation via synthetic Send: commands -------------------
// PrintHeadSimulator.addCommand() expects "Send: G1 X... Y... Z..." entries.
// Feed the movement commands for the current layer once per layer change.
// The simulator interpolates them over real time, animating the nozzle model.
if (layerIdx !== lastFedLayer && gcodeLayerMap.layerCmds[layerIdx]) {
lastFedLayer = layerIdx;
var cmds = gcodeLayerMap.layerCmds[layerIdx];
// PrintHeadSimulator buffer is capped at 1000; feed at most 400 commands
// so there's room for the sim to drain before more arrive.
logs = cmds.slice(0, 400).map(function (c) { return 'Send:' + c; });
}
}
viewModel.fromCurrentData({
job: {
file: {
path: currentFileId ? ('__bambuddy_file_' + currentFileId) : null,
date: currentFileDate,
},
estimatedPrintTime: null,
},
state: {
text: octoState,
flags: { printing: octoState === 'Printing', paused: octoState === 'Paused' },
},
progress: {
filepos: filepos,
completion: (printer.progress || 0) / 100,
printTime: null,
},
currentZ: null,
logs: logs,
});
}
// -------------------------------------------------------------------------
// 8. Auto-load file when printer starts printing
// -------------------------------------------------------------------------
function tryAutoLoadPrintingFile(filename) {
// Search the library for a matching .gcode file
apiFetch('/library/files?sort_by=updated_at&sort_dir=desc', {})
.then(function (r) { return r.json(); })
.then(function (files) {
if (!Array.isArray(files)) return;
var match = files.find(function (f) {
return f.filename === filename ||
f.filename === filename + '.gcode' ||
f.filename.replace(/\.gcode$/, '') === filename.replace(/\.gcode$/, '');
});
if (match) loadFileById(match.id, match.filename, match.file_size);
})
.catch(function () {});
}
// -------------------------------------------------------------------------
// 9. File loading
// -------------------------------------------------------------------------
function loadFileById(fileId, filename, fileSize) {
currentFileId = fileId;
currentFilename = filename;
currentFileDate = Date.now(); // new stable date so prettygcode loads exactly once
gcodeLayerMap = null; // cleared here; re-populated when fetch() intercept fires
lastFedLayer = -1;
stopPlayback(true);
updateFilenameDisplay(filename);
// Enable play button once a file is loaded
var playBtn = document.getElementById('bb-play-btn');
if (playBtn) playBtn.disabled = false;
// Trigger prettygcode.js's updateJob — date must match currentFileDate exactly
// so subsequent feedCurrentData calls don't re-trigger the download
if (viewModel && viewModel.fromCurrentData) {
viewModel.fromCurrentData({
job: {
file: {
path: '__bambuddy_file_' + fileId,
date: currentFileDate,
},
estimatedPrintTime: null,
},
state: { text: 'Operational', flags: { printing: false } },
progress: { filepos: null, completion: 0 },
currentZ: null,
logs: [],
});
}
}
function updateFilenameDisplay(filename) {
var el = document.getElementById('bb-current-file');
if (el) el.textContent = filename || '— no file loaded —';
}
// -------------------------------------------------------------------------
// 10. File picker
// -------------------------------------------------------------------------
function buildFilePicker() {
var container = document.getElementById('bb-file-picker');
if (!container) return;
var input = document.createElement('input');
input.type = 'text';
input.placeholder = 'Search .gcode files…';
input.className = 'bb-search';
input.style.cssText = 'width:100%;padding:4px 8px;background:#333;border:1px solid #555;color:#fff;border-radius:4px;margin-bottom:4px;box-sizing:border-box;';
var list = document.createElement('div');
list.style.cssText = 'max-height:180px;overflow-y:auto;';
container.appendChild(input);
container.appendChild(list);
var allFiles = [];
function render(files) {
list.innerHTML = '';
if (!files.length) {
list.innerHTML = '<div style="color:#888;padding:4px 6px;font-size:12px;">No .gcode files found in library</div>';
return;
}
files.forEach(function (f) {
var row = document.createElement('div');
row.textContent = f.filename;
row.title = f.filename;
row.style.cssText = 'padding:4px 6px;cursor:pointer;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;border-radius:3px;';
row.addEventListener('mouseenter', function () { row.style.background = '#444'; });
row.addEventListener('mouseleave', function () { row.style.background = ''; });
row.addEventListener('click', function () {
loadFileById(f.id, f.filename, f.file_size);
// Close picker
container.classList.toggle('bb-open', false);
});
list.appendChild(row);
});
}
function loadFiles() {
list.innerHTML = '<div style="color:#aaa;padding:4px 6px;font-size:12px;">Loading files…</div>';
// include_root=false returns files from ALL folders, not just root level
apiFetch('/library/files?include_root=false', {})
.then(function (r) { return r.json(); })
.then(function (files) {
if (!Array.isArray(files)) {
list.innerHTML = '<div style="color:#f88;padding:4px 6px;font-size:12px;">Failed to load files</div>';
return;
}
allFiles = files.filter(function (f) {
return f.filename && f.filename.toLowerCase().endsWith('.gcode');
});
render(allFiles);
})
.catch(function () {
list.innerHTML = '<div style="color:#f88;padding:4px 6px;font-size:12px;">Failed to load files — check auth token</div>';
});
}
input.addEventListener('input', function () {
var q = input.value.toLowerCase();
render(q ? allFiles.filter(function (f) { return f.filename.toLowerCase().indexOf(q) !== -1; }) : allFiles);
});
loadFiles();
}
// -------------------------------------------------------------------------
// 11. Printer selector
// -------------------------------------------------------------------------
function updatePrinterSelector() {
var sel = document.getElementById('bb-printer-select');
if (!sel) return;
var current = sel.value;
sel.innerHTML = '';
printers.forEach(function (p) {
var opt = document.createElement('option');
opt.value = p.id;
opt.textContent = (p.name || ('Printer ' + p.id)) + (p.state ? ' [' + p.state + ']' : '');
sel.appendChild(opt);
});
if (current) sel.value = current;
if (!sel.value && printers.length) {
sel.value = printers[0].id;
selectedPrinterId = printers[0].id;
}
}
// -------------------------------------------------------------------------
// 13. 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);
}
}
connectWebSocket();
// Wire up printer selector
var sel = document.getElementById('bb-printer-select');
if (sel) {
sel.addEventListener('change', function () {
selectedPrinterId = parseInt(sel.value, 10) || null;
});
}
// Load initial printer list
apiFetch('/printers', {})
.then(function (r) { return r.json(); })
.then(function (list) {
if (!Array.isArray(list)) return;
list.forEach(function (p) {
// Find existing entry (WS may have pushed one before API returned)
var existing = null;
for (var i = 0; i < printers.length; i++) {
if (printers[i].id === p.id) { existing = printers[i]; break; }
}
if (existing) {
// Fill in name/model that WS status messages don't carry
if (p.name) existing.name = p.name;
if (p.model) existing.model = p.model;
} else {
printers.push({ id: p.id, name: p.name, model: p.model, state: 'IDLE', progress: 0 });
}
});
updatePrinterSelector();
// Try to get bed size from first printer model
if (list.length > 0 && list[0].model) {
var m = list[0].model.toUpperCase();
for (var modelName in BAMBU_BED_SIZES) {
if (m.indexOf(modelName.toUpperCase()) !== -1) {
currentBed = BAMBU_BED_SIZES[modelName];
fakePrinterProfiles.currentProfileData(makeFakeProfileData(currentBed));
break;
}
}
}
if (list.length > 0) selectedPrinterId = list[0].id;
})
.catch(function () {});
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.
// buildFilePicker() runs immediately at DOM-ready — independent of viewmodel
// init so the file picker is always functional even if prettygcode fails.
// init() (viewmodel + 3D canvas) runs 200 ms later to let prettygcode.js
// finish its own synchronous setup first.
function onDomReady() {
// Wire file-picker button — MUST be here (not an inline <script>) because
// the CSP on this page allows script-src 'self' but NOT 'unsafe-inline',
// so inline <script> blocks are blocked by the browser.
var fileBtn = document.getElementById('bb-file-btn');
var picker = document.getElementById('bb-file-picker');
if (fileBtn && picker) {
fileBtn.addEventListener('click', function (e) {
picker.classList.toggle('bb-open');
e.stopPropagation();
});
// Clicking outside the picker closes it
document.addEventListener('click', function () {
picker.classList.remove('bb-open');
});
// Clicks inside the picker don't close it
picker.addEventListener('click', function (e) {
e.stopPropagation();
});
}
buildFilePicker();
setTimeout(init, 200);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', onDomReady);
} else {
onDomReady();
}
// -------------------------------------------------------------------------
// Public API
// -------------------------------------------------------------------------
window.BambuddyPrettyGCode = {
loadFile: loadFileById,
getViewModel: function () { return viewModel; },
play: startPlayback,
stop: stopPlayback,
};
})();

File diff suppressed because it is too large Load diff

2559
gcode_viewer/js/dat.gui.js Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

2
gcode_viewer/js/jquery.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,8 @@
# 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

View file

@ -0,0 +1,142 @@
/**
* 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
var isVertical = opts.orientation === 'vertical';
var trackHtml =
'<div class="slider' + (isVertical ? ' slider-vertical' : '') + '"' +
(opts.id ? ' id="' + 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
gcode_viewer/js/three.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -26,7 +26,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-aHxaU9HU.js"></script>
<script type="module" crossorigin src="/assets/index-BfEnlXcp.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BoxU3Y8Y.css">
</head>
<body>