mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-08-11 00:30:12 -04:00
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:
parent
5215ac68e9
commit
3adce435ee
38 changed files with 15895 additions and 67 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
85
backend/tests/integration/test_gcode_viewer.py
Normal file
85
backend/tests/integration/test_gcode_viewer.py
Normal 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
|
||||
115
backend/tests/unit/test_library_file_path_guard.py
Normal file
115
backend/tests/unit/test_library_file_path_guard.py
Normal 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()
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: 'パーツ',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: '零件',
|
||||
|
|
|
|||
|
|
@ -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: '零件',
|
||||
|
|
|
|||
28
frontend/src/pages/GCodeViewerPage.tsx
Normal file
28
frontend/src/pages/GCodeViewerPage.tsx
Normal 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',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
60
gcode_viewer/VENDORED.md
Normal 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
|
||||
417
gcode_viewer/css/prettygcode.css
Normal file
417
gcode_viewer/css/prettygcode.css
Normal 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
264
gcode_viewer/index.html
Normal 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">📄 Load file</button>
|
||||
<span id="bb-current-file">— no file loaded —</span>
|
||||
|
||||
<button id="bb-play-btn" title="Play layer animation" disabled>▶</button>
|
||||
<select id="bb-play-speed" title="Playback speed">
|
||||
<option value="1">1× slow</option>
|
||||
<option value="3" selected>3×</option>
|
||||
<option value="10">10× fast</option>
|
||||
<option value="25">25× 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">ⓘ</button>
|
||||
<button class="pgfilestoggle" title="Toggle file list">☰</button>
|
||||
<button class="pgsettingstoggle" title="Toggle settings">⚙</button>
|
||||
<button class="pgcameratoggle" title="Toggle webcam">📷</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
31
gcode_viewer/js/Line2.js
Normal 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;
|
||||
|
||||
}
|
||||
|
||||
} );
|
||||
104
gcode_viewer/js/LineGeometry.js
Normal file
104
gcode_viewer/js/LineGeometry.js
Normal 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;
|
||||
|
||||
}
|
||||
|
||||
} );
|
||||
391
gcode_viewer/js/LineMaterial.js
Normal file
391
gcode_viewer/js/LineMaterial.js
Normal 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;
|
||||
|
||||
};
|
||||
|
||||
65
gcode_viewer/js/LineSegments2.js
Normal file
65
gcode_viewer/js/LineSegments2.js
Normal 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;
|
||||
|
||||
}
|
||||
|
||||
} );
|
||||
258
gcode_viewer/js/LineSegmentsGeometry.js
Normal file
258
gcode_viewer/js/LineSegmentsGeometry.js
Normal 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
185
gcode_viewer/js/Lut.js
Normal 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 ]]
|
||||
|
||||
};
|
||||
797
gcode_viewer/js/OBJLoader.js
Normal file
797
gcode_viewer/js/OBJLoader.js
Normal 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;
|
||||
|
||||
} )();
|
||||
877
gcode_viewer/js/bambuddy_adapter.js
Normal file
877
gcode_viewer/js/bambuddy_adapter.js
Normal 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,
|
||||
};
|
||||
|
||||
})();
|
||||
1070
gcode_viewer/js/camera-controls.js
Normal file
1070
gcode_viewer/js/camera-controls.js
Normal file
File diff suppressed because it is too large
Load diff
2559
gcode_viewer/js/dat.gui.js
Normal file
2559
gcode_viewer/js/dat.gui.js
Normal file
File diff suppressed because one or more lines are too long
1
gcode_viewer/js/helvetiker_bold.typeface.json
Normal file
1
gcode_viewer/js/helvetiker_bold.typeface.json
Normal file
File diff suppressed because one or more lines are too long
2
gcode_viewer/js/jquery.min.js
vendored
Normal file
2
gcode_viewer/js/jquery.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
8
gcode_viewer/js/models/ExtruderNozzle.mtl
Normal file
8
gcode_viewer/js/models/ExtruderNozzle.mtl
Normal 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
|
||||
5282
gcode_viewer/js/models/ExtruderNozzle.obj
Normal file
5282
gcode_viewer/js/models/ExtruderNozzle.obj
Normal file
File diff suppressed because it is too large
Load diff
1932
gcode_viewer/js/prettygcode.js
Normal file
1932
gcode_viewer/js/prettygcode.js
Normal file
File diff suppressed because it is too large
Load diff
142
gcode_viewer/js/slider-shim.js
Normal file
142
gcode_viewer/js/slider-shim.js
Normal 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
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
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue