mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-08 23:21:58 +02: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:
@@ -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:
|
||||
|
||||
+61
-13
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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: '零件',
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+68
-1
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
|
||||
} );
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
|
||||
} );
|
||||
@@ -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;
|
||||
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
|
||||
} );
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
|
||||
} );
|
||||
@@ -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 ]]
|
||||
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
} )();
|
||||
@@ -0,0 +1,877 @@
|
||||
/**
|
||||
* bambuddy_adapter.js
|
||||
* Bridges OctoPrint-PrettyGCode to Bambuddy's API.
|
||||
*
|
||||
* Load this BEFORE prettygcode.js. It provides:
|
||||
* - OCTOPRINT_VIEWMODELS shim
|
||||
* - Minimal KnockoutJS observable shim (ko.observable)
|
||||
* - fetch() + XHR interceptors for path rewriting
|
||||
* - Bambuddy WebSocket → fromCurrentData bridge
|
||||
* - File picker backed by Bambuddy's library API
|
||||
* - Settings load/save via plugin settings endpoint
|
||||
*
|
||||
* What works:
|
||||
* - Full 3D GCode visualisation
|
||||
* - Dark mode and all dat.GUI settings
|
||||
* - File selection from Bambuddy's file library
|
||||
* - Print progress highlight (% based)
|
||||
* - Auto-load currently printing file
|
||||
*
|
||||
* What doesn't work (Bambu hardware limitation):
|
||||
* - Live nozzle animation during printing — Bambu printers do not expose
|
||||
* GCode serial echo logs (Send: G1 X...), so PrintHeadSimulator has no input.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const API_BASE = '/api/v1';
|
||||
const VIEWER_BASE = '/gcode-viewer'; // static assets now served from here
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Auth helper
|
||||
// -------------------------------------------------------------------------
|
||||
function authHeaders() {
|
||||
// sessionStorage is used when the user opts out of "remember me";
|
||||
// fall back to localStorage for persistent sessions.
|
||||
const token = sessionStorage.getItem('auth_token') ?? localStorage.getItem('auth_token');
|
||||
return token ? { Authorization: 'Bearer ' + token } : {};
|
||||
}
|
||||
|
||||
// When auth is enabled and the user has no valid token, every API call
|
||||
// returns 401 and the viewer chrome stays on screen showing empty state.
|
||||
// Intercept the first 401 and hand control back to the SPA, which owns
|
||||
// the login flow and will redirect to /login when appropriate.
|
||||
let _authRedirectFired = false;
|
||||
function apiFetch(path, opts) {
|
||||
return fetch(API_BASE + path, {
|
||||
...opts,
|
||||
headers: { ...authHeaders(), ...(opts && opts.headers) },
|
||||
cache: 'no-store',
|
||||
}).then((response) => {
|
||||
if (response.status === 401 && !_authRedirectFired) {
|
||||
_authRedirectFired = true;
|
||||
try {
|
||||
sessionStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('auth_token');
|
||||
} catch (e) { /* storage unavailable */ }
|
||||
window.top.location.replace('/');
|
||||
}
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 1. Minimal KnockoutJS shim (ko.observable / ko.computed)
|
||||
// -------------------------------------------------------------------------
|
||||
window.ko = {
|
||||
observable: function (initial) {
|
||||
var _val = initial;
|
||||
var _subs = [];
|
||||
var obs = function (newVal) {
|
||||
if (arguments.length > 0) {
|
||||
_val = newVal;
|
||||
_subs.forEach(function (cb) { try { cb(newVal); } catch (e) {} });
|
||||
}
|
||||
return _val;
|
||||
};
|
||||
obs.subscribe = function (cb) {
|
||||
_subs.push(cb);
|
||||
return { dispose: function () { _subs = _subs.filter(function (s) { return s !== cb; }); } };
|
||||
};
|
||||
obs.peek = function () { return _val; };
|
||||
return obs;
|
||||
},
|
||||
computed: function (fn) {
|
||||
var obs = window.ko.observable(null);
|
||||
try { obs(fn()); } catch (e) {}
|
||||
return obs;
|
||||
},
|
||||
pureComputed: function (fn) { return window.ko.computed(fn); },
|
||||
mapping: { fromJS: function (obj) { return obj; } },
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 2. OCTOPRINT_VIEWMODELS registration shim
|
||||
// -------------------------------------------------------------------------
|
||||
window.OCTOPRINT_VIEWMODELS = [];
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 3. Fake OctoPrint settings / printer profile / login viewmodels
|
||||
// -------------------------------------------------------------------------
|
||||
var fakeSettings = {
|
||||
webcam: {
|
||||
streamUrl: ko.observable(''),
|
||||
flipH: ko.observable(false),
|
||||
flipV: ko.observable(false),
|
||||
rotate90: ko.observable(false),
|
||||
},
|
||||
plugins: {
|
||||
prettygcode: {
|
||||
darkMode: ko.observable(false),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Bed sizes for common Bambu models (mm)
|
||||
var BAMBU_BED_SIZES = {
|
||||
'X1': { width: 256, depth: 256, height: 256 },
|
||||
'X1C': { width: 256, depth: 256, height: 256 },
|
||||
'X1E': { width: 256, depth: 256, height: 256 },
|
||||
'P1S': { width: 256, depth: 256, height: 256 },
|
||||
'P1P': { width: 256, depth: 256, height: 256 },
|
||||
'A1': { width: 300, depth: 300, height: 300 },
|
||||
'A1 Mini': { width: 180, depth: 180, height: 180 },
|
||||
};
|
||||
var DEFAULT_BED = { width: 256, depth: 256, height: 256 };
|
||||
|
||||
var currentBed = Object.assign({}, DEFAULT_BED);
|
||||
|
||||
function makeFakeProfileData(bed) {
|
||||
return {
|
||||
volume: {
|
||||
width: ko.observable(bed.width),
|
||||
depth: ko.observable(bed.depth),
|
||||
height: ko.observable(bed.height),
|
||||
origin: ko.observable('lowerleft'),
|
||||
formFactor: ko.observable('rectangular'),
|
||||
// Make custom_box a function so prettygcode.js uses width()/depth()/height()
|
||||
custom_box: function () { return false; },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
var fakePrinterProfiles = {
|
||||
currentProfileData: ko.observable(makeFakeProfileData(currentBed)),
|
||||
};
|
||||
|
||||
var fakeLoginState = {
|
||||
isUser: ko.observable(true),
|
||||
isAdmin: ko.observable(false),
|
||||
};
|
||||
|
||||
var fakeControl = {};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 4. fetch() interceptor — rewrite OctoPrint paths to Bambuddy
|
||||
// -------------------------------------------------------------------------
|
||||
var _originalFetch = window.fetch.bind(window);
|
||||
window.fetch = function (resource, init) {
|
||||
var url = (typeof resource === 'string') ? resource
|
||||
: (resource && resource.url) ? resource.url
|
||||
: null;
|
||||
|
||||
if (url) {
|
||||
// Normalize: strip scheme+host so regexes work on the path regardless
|
||||
// of whether the browser resolved a relative URL to absolute.
|
||||
var path = url.replace(/^https?:\/\/[^\/]+/, '');
|
||||
// Also strip the viewer's own path prefix — the browser resolves relative URLs
|
||||
// like 'downloads/files/local/...' to '/gcode-viewer/downloads/...' because
|
||||
// the page is served from /gcode-viewer/. The regexes below expect bare paths.
|
||||
path = path.replace(/^\/gcode-viewer(?=\/|$)/, '');
|
||||
|
||||
var newPath = path;
|
||||
|
||||
// OctoPrint file download → Bambuddy library download
|
||||
newPath = newPath.replace(
|
||||
/^\/?downloads\/files\/local\/__bambuddy_file_(\d+)$/,
|
||||
API_BASE + '/library/files/$1/download'
|
||||
);
|
||||
// OctoPrint plugin static assets → gcode-viewer static files
|
||||
newPath = newPath.replace(
|
||||
/^\/?plugin\/prettygcode\/static\//,
|
||||
VIEWER_BASE + '/'
|
||||
);
|
||||
|
||||
if (newPath !== path) {
|
||||
url = newPath;
|
||||
resource = url; // always pass as string after rewriting
|
||||
}
|
||||
|
||||
// Inject auth header for all Bambuddy API calls
|
||||
if (url.startsWith(API_BASE)) {
|
||||
var hdrs = authHeaders();
|
||||
init = init || {};
|
||||
init.headers = Object.assign({}, hdrs, init.headers || {});
|
||||
}
|
||||
}
|
||||
|
||||
var promise = _originalFetch(resource, init);
|
||||
|
||||
// Tee GCode downloads to build the layer map for sync + nozzle animation
|
||||
if (url && url.match(/\/library\/files\/\d+\/download/)) {
|
||||
promise = promise.then(function (response) {
|
||||
var clone = response.clone();
|
||||
clone.text().then(function (text) {
|
||||
gcodeLayerMap = parseGcodeLayerMap(text);
|
||||
lastFedLayer = -1;
|
||||
console.log('[PrettyGCode] Parsed ' + gcodeLayerMap.layerOffsets.length +
|
||||
' layers for sync (' + Math.round(gcodeLayerMap.totalBytes / 1024) + ' KB)');
|
||||
}).catch(function (e) {
|
||||
console.warn('[PrettyGCode] GCode layer parse failed:', e);
|
||||
});
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
return promise;
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 5. XHR interceptor — rewrite OctoPrint paths (used by THREE.OBJLoader etc.)
|
||||
// -------------------------------------------------------------------------
|
||||
var _origXHROpen = XMLHttpRequest.prototype.open;
|
||||
XMLHttpRequest.prototype.open = function (method, url) {
|
||||
if (typeof url === 'string') {
|
||||
// Strip host if absolute, then rewrite OctoPrint static asset paths
|
||||
var path = url.replace(/^https?:\/\/[^\/]+/, '');
|
||||
path = path.replace(/^\/?plugin\/prettygcode\/static\//, VIEWER_BASE + '/');
|
||||
url = path;
|
||||
}
|
||||
var args = Array.prototype.slice.call(arguments);
|
||||
args[1] = url;
|
||||
return _origXHROpen.apply(this, args);
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 6. GCode layer parser
|
||||
//
|
||||
// Builds a layer map from the raw GCode text so we can:
|
||||
// a) Map layer_num → byte offset in file (drives prettygcode's filepos sync
|
||||
// and layer highlight, same as if OctoPrint were reporting filepos)
|
||||
// b) Extract a set of G0/G1 commands per layer to feed the PrintHeadSimulator
|
||||
// as synthetic "Send: G1 X... Y... Z..." entries, animating the nozzle model.
|
||||
//
|
||||
// Layer detection mirrors prettygcode.js: a new layer starts on the first extrusion
|
||||
// at a Z position we haven't extruded at before.
|
||||
// -------------------------------------------------------------------------
|
||||
function parseGcodeLayerMap(text) {
|
||||
var lines = text.split('\n');
|
||||
var layerOffsets = []; // layerOffsets[i] = byte pos in file where layer i starts
|
||||
var layerCmds = []; // layerCmds[i] = array of ' G1 X... Y... Z...' strings
|
||||
var byteOffset = 0;
|
||||
var x = 0, y = 0, z = 0, e = 0;
|
||||
var relative = false, relativeE = false;
|
||||
var currentLayerZ = null;
|
||||
var curCmds = [];
|
||||
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var raw = lines[i];
|
||||
// +1 for the \n that was consumed by split
|
||||
var lineBytes = raw.length + 1;
|
||||
|
||||
var cmd = raw.replace(/;.*$/, '').trim();
|
||||
if (!cmd) { byteOffset += lineBytes; continue; }
|
||||
|
||||
var parts = cmd.split(/\s+/);
|
||||
var g = parts[0].toUpperCase();
|
||||
|
||||
if (g === 'G90') { relative = false; relativeE = false; }
|
||||
else if (g === 'G91') { relative = true; relativeE = true; }
|
||||
else if (g === 'M82') { relativeE = false; }
|
||||
else if (g === 'M83') { relativeE = true; }
|
||||
else if (g === 'G92') {
|
||||
// coordinate reset
|
||||
for (var p = 1; p < parts.length; p++) {
|
||||
var k0 = parts[p][0].toUpperCase();
|
||||
var v0 = parseFloat(parts[p].slice(1));
|
||||
if (!isNaN(v0)) {
|
||||
if (k0 === 'X') x = v0;
|
||||
else if (k0 === 'Y') y = v0;
|
||||
else if (k0 === 'Z') z = v0;
|
||||
else if (k0 === 'E') e = v0;
|
||||
}
|
||||
}
|
||||
} else if (g === 'G0' || g === 'G1') {
|
||||
var nx = x, ny = y, nz = z, ne = e;
|
||||
var hasE = false;
|
||||
for (var p = 1; p < parts.length; p++) {
|
||||
if (!parts[p]) continue;
|
||||
var k1 = parts[p][0].toUpperCase();
|
||||
var v1 = parseFloat(parts[p].slice(1));
|
||||
if (isNaN(v1)) continue;
|
||||
if (k1 === 'X') nx = relative ? x + v1 : v1;
|
||||
else if (k1 === 'Y') ny = relative ? y + v1 : v1;
|
||||
else if (k1 === 'Z') nz = relative ? z + v1 : v1;
|
||||
else if (k1 === 'E') { ne = relativeE ? e + v1 : v1; hasE = true; }
|
||||
}
|
||||
|
||||
// New layer: first extrusion at a new Z (same logic as prettygcode.js)
|
||||
if (hasE && ne > e && nz !== currentLayerZ) {
|
||||
currentLayerZ = nz;
|
||||
if (curCmds.length > 0) layerCmds.push(curCmds);
|
||||
else if (layerOffsets.length > 0) layerCmds.push([]); // gap layer
|
||||
curCmds = [];
|
||||
layerOffsets.push(byteOffset);
|
||||
}
|
||||
|
||||
// Record movement commands for nozzle sim (keep arrays small — max 500/layer)
|
||||
if ((hasE || nz !== z) && curCmds.length < 500) {
|
||||
curCmds.push(' G1 X' + nx.toFixed(3) +
|
||||
' Y' + ny.toFixed(3) +
|
||||
' Z' + nz.toFixed(3));
|
||||
}
|
||||
|
||||
x = nx; y = ny; z = nz; e = ne;
|
||||
}
|
||||
|
||||
byteOffset += lineBytes;
|
||||
}
|
||||
if (curCmds.length > 0) layerCmds.push(curCmds);
|
||||
|
||||
return {
|
||||
layerOffsets: layerOffsets,
|
||||
layerCmds: layerCmds,
|
||||
totalBytes: byteOffset,
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 8. State
|
||||
// -------------------------------------------------------------------------
|
||||
var viewModel = null;
|
||||
var currentFileId = null;
|
||||
var currentFilename = null;
|
||||
var currentFileDate = 0; // stable epoch — only changes when a new file is loaded
|
||||
var ws = null;
|
||||
var wsReconnectTimer = null;
|
||||
var printers = []; // [{id, name, model, state, progress, subtask_name}]
|
||||
var selectedPrinterId = null;
|
||||
var gcodeLayerMap = null; // parsed layer data: {layerOffsets, layerCmds, totalBytes}
|
||||
var lastFedLayer = -1; // last layer_num whose commands we fed to printHeadSim
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 9. Bambuddy WebSocket
|
||||
// -------------------------------------------------------------------------
|
||||
function connectWebSocket() {
|
||||
var token = localStorage.getItem('auth_token');
|
||||
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
// Do NOT put the token in the URL — it would appear in server logs.
|
||||
// The WebSocket endpoint is currently unauthenticated server-side;
|
||||
// all sensitive calls go through authenticated fetch() instead.
|
||||
var wsUrl = proto + '//' + location.host + API_BASE + '/ws';
|
||||
|
||||
ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = function () {
|
||||
console.log('[PrettyGCode] Connected to Bambuddy WebSocket');
|
||||
};
|
||||
|
||||
ws.onmessage = function (event) {
|
||||
try {
|
||||
var msg = JSON.parse(event.data);
|
||||
if (msg.type === 'printer_status') {
|
||||
handlePrinterStatus(msg.printer_id, msg.data);
|
||||
}
|
||||
} catch (e) {}
|
||||
};
|
||||
|
||||
ws.onclose = function () {
|
||||
clearTimeout(wsReconnectTimer);
|
||||
wsReconnectTimer = setTimeout(connectWebSocket, 3000);
|
||||
};
|
||||
|
||||
ws.onerror = function () {
|
||||
ws.close();
|
||||
};
|
||||
}
|
||||
|
||||
function bambuStateToOctoState(bambuState) {
|
||||
var map = {
|
||||
RUNNING: 'Printing',
|
||||
PAUSE: 'Paused',
|
||||
FAILED: 'Error',
|
||||
FINISH: 'Operational',
|
||||
IDLE: 'Operational',
|
||||
};
|
||||
return map[bambuState] || 'Operational';
|
||||
}
|
||||
|
||||
function handlePrinterStatus(printerId, data) {
|
||||
// Update printer list entry
|
||||
var found = false;
|
||||
for (var i = 0; i < printers.length; i++) {
|
||||
if (printers[i].id === printerId) {
|
||||
// Allowlist to prevent prototype pollution from crafted WS messages
|
||||
var allowed2 = ['name', 'state', 'progress', 'layer_num', 'subtask_name', 'gcode_file', 'camera_url', 'model'];
|
||||
allowed2.forEach(function (k) { if (k in data) printers[i][k] = data[k]; });
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
// Only copy known, safe keys — avoids prototype pollution from a crafted WS message
|
||||
var allowed = ['name', 'state', 'progress', 'layer_num', 'subtask_name', 'gcode_file', 'camera_url', 'model'];
|
||||
var entry = { id: printerId };
|
||||
allowed.forEach(function (k) { if (k in data) entry[k] = data[k]; });
|
||||
printers.push(entry);
|
||||
}
|
||||
|
||||
updatePrinterSelector();
|
||||
|
||||
// Only feed data for the selected printer
|
||||
if (selectedPrinterId !== null && printerId !== selectedPrinterId) return;
|
||||
if (selectedPrinterId === null && printers.length > 0) {
|
||||
selectedPrinterId = printers[0].id;
|
||||
}
|
||||
|
||||
if (!viewModel) return;
|
||||
|
||||
var printer = null;
|
||||
for (var j = 0; j < printers.length; j++) {
|
||||
if (printers[j].id === printerId) { printer = printers[j]; break; }
|
||||
}
|
||||
if (!printer) return;
|
||||
|
||||
// Update bed size from printer model
|
||||
var bedKey = (printer.model || '').toUpperCase();
|
||||
for (var modelName in BAMBU_BED_SIZES) {
|
||||
if (bedKey.indexOf(modelName.toUpperCase()) !== -1) {
|
||||
currentBed = BAMBU_BED_SIZES[modelName];
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Replace the entire profile data so the subscribe() fires
|
||||
fakePrinterProfiles.currentProfileData(makeFakeProfileData(currentBed));
|
||||
|
||||
// Auto-load currently printing file if it changed
|
||||
var subtask = printer.subtask_name || printer.gcode_file || '';
|
||||
if (subtask && subtask !== currentFilename) {
|
||||
currentFilename = subtask;
|
||||
tryAutoLoadPrintingFile(subtask);
|
||||
}
|
||||
|
||||
// Update webcam URL
|
||||
if (printer.camera_url) {
|
||||
fakeSettings.webcam.streamUrl(printer.camera_url);
|
||||
}
|
||||
|
||||
feedCurrentData(printer);
|
||||
}
|
||||
|
||||
function feedCurrentData(printer) {
|
||||
if (!viewModel || !viewModel.fromCurrentData) return;
|
||||
var octoState = bambuStateToOctoState(printer.state || 'IDLE');
|
||||
var isPrinting = octoState === 'Printing' || octoState === 'Paused';
|
||||
|
||||
// --- Layer sync via filepos -------------------------------------------
|
||||
// prettygcode.js calls gcodeProxy.syncGcodeObjToFilePos(curPrintFilePos) each
|
||||
// animation frame when printing + syncToProgress is on. Pass the byte offset
|
||||
// of the current layer so the highlight advances correctly.
|
||||
var filepos = null;
|
||||
var logs = [];
|
||||
|
||||
if (gcodeLayerMap && isPrinting) {
|
||||
// Bambu layer_num is 1-based; our layerOffsets array is 0-based.
|
||||
var layerIdx = Math.max(0, (printer.layer_num || 1) - 1);
|
||||
layerIdx = Math.min(layerIdx, gcodeLayerMap.layerOffsets.length - 1);
|
||||
filepos = gcodeLayerMap.layerOffsets[layerIdx] || 0;
|
||||
|
||||
// --- Nozzle animation via synthetic Send: commands -------------------
|
||||
// PrintHeadSimulator.addCommand() expects "Send: G1 X... Y... Z..." entries.
|
||||
// Feed the movement commands for the current layer once per layer change.
|
||||
// The simulator interpolates them over real time, animating the nozzle model.
|
||||
if (layerIdx !== lastFedLayer && gcodeLayerMap.layerCmds[layerIdx]) {
|
||||
lastFedLayer = layerIdx;
|
||||
var cmds = gcodeLayerMap.layerCmds[layerIdx];
|
||||
// PrintHeadSimulator buffer is capped at 1000; feed at most 400 commands
|
||||
// so there's room for the sim to drain before more arrive.
|
||||
logs = cmds.slice(0, 400).map(function (c) { return 'Send:' + c; });
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.fromCurrentData({
|
||||
job: {
|
||||
file: {
|
||||
path: currentFileId ? ('__bambuddy_file_' + currentFileId) : null,
|
||||
date: currentFileDate,
|
||||
},
|
||||
estimatedPrintTime: null,
|
||||
},
|
||||
state: {
|
||||
text: octoState,
|
||||
flags: { printing: octoState === 'Printing', paused: octoState === 'Paused' },
|
||||
},
|
||||
progress: {
|
||||
filepos: filepos,
|
||||
completion: (printer.progress || 0) / 100,
|
||||
printTime: null,
|
||||
},
|
||||
currentZ: null,
|
||||
logs: logs,
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 8. Auto-load file when printer starts printing
|
||||
// -------------------------------------------------------------------------
|
||||
function tryAutoLoadPrintingFile(filename) {
|
||||
// Search the library for a matching .gcode file
|
||||
apiFetch('/library/files?sort_by=updated_at&sort_dir=desc', {})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (files) {
|
||||
if (!Array.isArray(files)) return;
|
||||
var match = files.find(function (f) {
|
||||
return f.filename === filename ||
|
||||
f.filename === filename + '.gcode' ||
|
||||
f.filename.replace(/\.gcode$/, '') === filename.replace(/\.gcode$/, '');
|
||||
});
|
||||
if (match) loadFileById(match.id, match.filename, match.file_size);
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 9. File loading
|
||||
// -------------------------------------------------------------------------
|
||||
function loadFileById(fileId, filename, fileSize) {
|
||||
currentFileId = fileId;
|
||||
currentFilename = filename;
|
||||
currentFileDate = Date.now(); // new stable date so prettygcode loads exactly once
|
||||
gcodeLayerMap = null; // cleared here; re-populated when fetch() intercept fires
|
||||
lastFedLayer = -1;
|
||||
stopPlayback(true);
|
||||
updateFilenameDisplay(filename);
|
||||
// Enable play button once a file is loaded
|
||||
var playBtn = document.getElementById('bb-play-btn');
|
||||
if (playBtn) playBtn.disabled = false;
|
||||
// Trigger prettygcode.js's updateJob — date must match currentFileDate exactly
|
||||
// so subsequent feedCurrentData calls don't re-trigger the download
|
||||
if (viewModel && viewModel.fromCurrentData) {
|
||||
viewModel.fromCurrentData({
|
||||
job: {
|
||||
file: {
|
||||
path: '__bambuddy_file_' + fileId,
|
||||
date: currentFileDate,
|
||||
},
|
||||
estimatedPrintTime: null,
|
||||
},
|
||||
state: { text: 'Operational', flags: { printing: false } },
|
||||
progress: { filepos: null, completion: 0 },
|
||||
currentZ: null,
|
||||
logs: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function updateFilenameDisplay(filename) {
|
||||
var el = document.getElementById('bb-current-file');
|
||||
if (el) el.textContent = filename || '— no file loaded —';
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 10. File picker
|
||||
// -------------------------------------------------------------------------
|
||||
function buildFilePicker() {
|
||||
var container = document.getElementById('bb-file-picker');
|
||||
if (!container) return;
|
||||
|
||||
var input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.placeholder = 'Search .gcode files…';
|
||||
input.className = 'bb-search';
|
||||
input.style.cssText = 'width:100%;padding:4px 8px;background:#333;border:1px solid #555;color:#fff;border-radius:4px;margin-bottom:4px;box-sizing:border-box;';
|
||||
|
||||
var list = document.createElement('div');
|
||||
list.style.cssText = 'max-height:180px;overflow-y:auto;';
|
||||
|
||||
container.appendChild(input);
|
||||
container.appendChild(list);
|
||||
|
||||
var allFiles = [];
|
||||
|
||||
function render(files) {
|
||||
list.innerHTML = '';
|
||||
if (!files.length) {
|
||||
list.innerHTML = '<div style="color:#888;padding:4px 6px;font-size:12px;">No .gcode files found in library</div>';
|
||||
return;
|
||||
}
|
||||
files.forEach(function (f) {
|
||||
var row = document.createElement('div');
|
||||
row.textContent = f.filename;
|
||||
row.title = f.filename;
|
||||
row.style.cssText = 'padding:4px 6px;cursor:pointer;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;border-radius:3px;';
|
||||
row.addEventListener('mouseenter', function () { row.style.background = '#444'; });
|
||||
row.addEventListener('mouseleave', function () { row.style.background = ''; });
|
||||
row.addEventListener('click', function () {
|
||||
loadFileById(f.id, f.filename, f.file_size);
|
||||
// Close picker
|
||||
container.classList.toggle('bb-open', false);
|
||||
});
|
||||
list.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function loadFiles() {
|
||||
list.innerHTML = '<div style="color:#aaa;padding:4px 6px;font-size:12px;">Loading files…</div>';
|
||||
// include_root=false returns files from ALL folders, not just root level
|
||||
apiFetch('/library/files?include_root=false', {})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (files) {
|
||||
if (!Array.isArray(files)) {
|
||||
list.innerHTML = '<div style="color:#f88;padding:4px 6px;font-size:12px;">Failed to load files</div>';
|
||||
return;
|
||||
}
|
||||
allFiles = files.filter(function (f) {
|
||||
return f.filename && f.filename.toLowerCase().endsWith('.gcode');
|
||||
});
|
||||
render(allFiles);
|
||||
})
|
||||
.catch(function () {
|
||||
list.innerHTML = '<div style="color:#f88;padding:4px 6px;font-size:12px;">Failed to load files — check auth token</div>';
|
||||
});
|
||||
}
|
||||
|
||||
input.addEventListener('input', function () {
|
||||
var q = input.value.toLowerCase();
|
||||
render(q ? allFiles.filter(function (f) { return f.filename.toLowerCase().indexOf(q) !== -1; }) : allFiles);
|
||||
});
|
||||
|
||||
loadFiles();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 11. Printer selector
|
||||
// -------------------------------------------------------------------------
|
||||
function updatePrinterSelector() {
|
||||
var sel = document.getElementById('bb-printer-select');
|
||||
if (!sel) return;
|
||||
var current = sel.value;
|
||||
sel.innerHTML = '';
|
||||
printers.forEach(function (p) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = p.id;
|
||||
opt.textContent = (p.name || ('Printer ' + p.id)) + (p.state ? ' [' + p.state + ']' : '');
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
if (current) sel.value = current;
|
||||
if (!sel.value && printers.length) {
|
||||
sel.value = printers[0].id;
|
||||
selectedPrinterId = printers[0].id;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 13. Initialise after DOM + scripts are ready
|
||||
// -------------------------------------------------------------------------
|
||||
function init() {
|
||||
// Find the ViewModel registration that prettygcode.js pushed
|
||||
var reg = null;
|
||||
for (var i = 0; i < window.OCTOPRINT_VIEWMODELS.length; i++) {
|
||||
if (window.OCTOPRINT_VIEWMODELS[i].construct) {
|
||||
reg = window.OCTOPRINT_VIEWMODELS[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!reg) {
|
||||
console.error('[PrettyGCode] No ViewModel found in OCTOPRINT_VIEWMODELS');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
viewModel = new reg.construct([
|
||||
fakeSettings,
|
||||
fakeLoginState,
|
||||
fakePrinterProfiles,
|
||||
fakeControl,
|
||||
]);
|
||||
} catch (e) {
|
||||
console.error('[PrettyGCode] ViewModel constructor failed:', e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (viewModel.onAfterBinding) {
|
||||
try { viewModel.onAfterBinding(); } catch (e) {}
|
||||
}
|
||||
|
||||
// Trigger tab activation — this calls onTabChange which initialises the Three.js scene
|
||||
if (viewModel.onTabChange) {
|
||||
try { viewModel.onTabChange('#tab_plugin_prettygcode', ''); } catch (e) {
|
||||
console.error('[PrettyGCode] onTabChange failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
connectWebSocket();
|
||||
|
||||
// Wire up printer selector
|
||||
var sel = document.getElementById('bb-printer-select');
|
||||
if (sel) {
|
||||
sel.addEventListener('change', function () {
|
||||
selectedPrinterId = parseInt(sel.value, 10) || null;
|
||||
});
|
||||
}
|
||||
|
||||
// Load initial printer list
|
||||
apiFetch('/printers', {})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (list) {
|
||||
if (!Array.isArray(list)) return;
|
||||
list.forEach(function (p) {
|
||||
// Find existing entry (WS may have pushed one before API returned)
|
||||
var existing = null;
|
||||
for (var i = 0; i < printers.length; i++) {
|
||||
if (printers[i].id === p.id) { existing = printers[i]; break; }
|
||||
}
|
||||
if (existing) {
|
||||
// Fill in name/model that WS status messages don't carry
|
||||
if (p.name) existing.name = p.name;
|
||||
if (p.model) existing.model = p.model;
|
||||
} else {
|
||||
printers.push({ id: p.id, name: p.name, model: p.model, state: 'IDLE', progress: 0 });
|
||||
}
|
||||
});
|
||||
updatePrinterSelector();
|
||||
// Try to get bed size from first printer model
|
||||
if (list.length > 0 && list[0].model) {
|
||||
var m = list[0].model.toUpperCase();
|
||||
for (var modelName in BAMBU_BED_SIZES) {
|
||||
if (m.indexOf(modelName.toUpperCase()) !== -1) {
|
||||
currentBed = BAMBU_BED_SIZES[modelName];
|
||||
fakePrinterProfiles.currentProfileData(makeFakeProfileData(currentBed));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (list.length > 0) selectedPrinterId = list[0].id;
|
||||
})
|
||||
.catch(function () {});
|
||||
|
||||
console.log('[PrettyGCode] Bambuddy adapter initialised');
|
||||
|
||||
// Wire up playback controls
|
||||
var playBtn = document.getElementById('bb-play-btn');
|
||||
var speedSel = document.getElementById('bb-play-speed');
|
||||
if (playBtn) {
|
||||
playBtn.addEventListener('click', function () {
|
||||
if (isPlaying) stopPlayback();
|
||||
else startPlayback();
|
||||
});
|
||||
}
|
||||
if (speedSel) {
|
||||
speedSel.addEventListener('change', function () {
|
||||
layersPerTick = parseInt(speedSel.value, 10) || 1;
|
||||
// Restart if already playing so speed takes effect immediately
|
||||
if (isPlaying) { stopPlayback(); startPlayback(); }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 14. Playback engine
|
||||
// -------------------------------------------------------------------------
|
||||
var isPlaying = false;
|
||||
var playInterval = null;
|
||||
var layersPerTick = 1; // layers advanced per 50 ms tick
|
||||
var TICK_MS = 50; // ~20 fps
|
||||
|
||||
function getSlider() { return $('#myslider-vertical'); }
|
||||
|
||||
function startPlayback() {
|
||||
var $sl = getSlider();
|
||||
if (!$sl.length) return;
|
||||
var data = $sl.data('_pgslider');
|
||||
if (!data) return;
|
||||
|
||||
var max = data.opts.max || 0;
|
||||
if (max === 0) return;
|
||||
|
||||
// Restart from beginning if already at the end
|
||||
var cur = data.opts.value || 0;
|
||||
if (cur >= max) cur = 0;
|
||||
|
||||
// Suppress live-print sync while playing
|
||||
var evStart = $.Event('slideStart'); evStart.value = cur; $sl.trigger(evStart);
|
||||
|
||||
_setSliderLayer($sl, cur);
|
||||
|
||||
isPlaying = true;
|
||||
_updatePlayBtn();
|
||||
|
||||
playInterval = setInterval(function () {
|
||||
var d = getSlider().data('_pgslider');
|
||||
if (!d) { stopPlayback(); return; }
|
||||
var next = (d.opts.value || 0) + layersPerTick;
|
||||
if (next >= d.opts.max) {
|
||||
next = d.opts.max;
|
||||
_setSliderLayer(getSlider(), next);
|
||||
stopPlayback(/* skipEvStop */ false);
|
||||
return;
|
||||
}
|
||||
_setSliderLayer(getSlider(), next);
|
||||
}, TICK_MS);
|
||||
}
|
||||
|
||||
function stopPlayback(skipEvStop) {
|
||||
if (playInterval) { clearInterval(playInterval); playInterval = null; }
|
||||
isPlaying = false;
|
||||
_updatePlayBtn();
|
||||
if (!skipEvStop) {
|
||||
var $sl = getSlider();
|
||||
if ($sl.length) {
|
||||
var d = $sl.data('_pgslider');
|
||||
var evStop = $.Event('slideStop');
|
||||
evStop.value = d ? d.opts.value : 0;
|
||||
$sl.trigger(evStop);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _setSliderLayer($sl, layer) {
|
||||
$sl.slider('setValue', layer);
|
||||
var ev = $.Event('slide'); ev.value = layer; $sl.trigger(ev);
|
||||
$sl.find('.slider-handle').text(layer);
|
||||
}
|
||||
|
||||
function _updatePlayBtn() {
|
||||
var btn = document.getElementById('bb-play-btn');
|
||||
if (btn) btn.textContent = isPlaying ? '⏸' : '▶';
|
||||
}
|
||||
|
||||
// Run after all scripts have loaded.
|
||||
// buildFilePicker() runs immediately at DOM-ready — independent of viewmodel
|
||||
// init so the file picker is always functional even if prettygcode fails.
|
||||
// init() (viewmodel + 3D canvas) runs 200 ms later to let prettygcode.js
|
||||
// finish its own synchronous setup first.
|
||||
function onDomReady() {
|
||||
// Wire file-picker button — MUST be here (not an inline <script>) because
|
||||
// the CSP on this page allows script-src 'self' but NOT 'unsafe-inline',
|
||||
// so inline <script> blocks are blocked by the browser.
|
||||
var fileBtn = document.getElementById('bb-file-btn');
|
||||
var picker = document.getElementById('bb-file-picker');
|
||||
if (fileBtn && picker) {
|
||||
fileBtn.addEventListener('click', function (e) {
|
||||
picker.classList.toggle('bb-open');
|
||||
e.stopPropagation();
|
||||
});
|
||||
// Clicking outside the picker closes it
|
||||
document.addEventListener('click', function () {
|
||||
picker.classList.remove('bb-open');
|
||||
});
|
||||
// Clicks inside the picker don't close it
|
||||
picker.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
});
|
||||
}
|
||||
|
||||
buildFilePicker();
|
||||
setTimeout(init, 200);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', onDomReady);
|
||||
} else {
|
||||
onDomReady();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Public API
|
||||
// -------------------------------------------------------------------------
|
||||
window.BambuddyPrettyGCode = {
|
||||
loadFile: loadFileById,
|
||||
getViewModel: function () { return viewModel; },
|
||||
play: startPlayback,
|
||||
stop: stopPlayback,
|
||||
};
|
||||
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+2
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
|
||||
# Exported with QuadFace Tools (0.14.0)
|
||||
# SketchUp 17.2.2555
|
||||
# Model name: ExtruderNozzle
|
||||
|
||||
newmtl FrontColor
|
||||
Ka 0.000000 0.000000 0.000000
|
||||
Kd 1.000000 1.000000 1.000000
|
||||
Ks 0.330000 0.330000 0.330000
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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));
|
||||
Vendored
+996
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user