feat: add embedded GCode viewer (#963)

* feat: add embedded GCode viewer

Adds PrettyGCode as a built-in GCode visualiser embedded directly in the
Bambuddy layout, so users can preview and inspect GCode files without
leaving the dashboard.
This commit is contained in:
Nathen Fredrick
2026-04-22 11:14:42 +02:00
committed by GitHub
parent 5215ac68e9
commit 3adce435ee
38 changed files with 15895 additions and 67 deletions
+60
View File
@@ -0,0 +1,60 @@
# Third-Party Notices — gcode_viewer
The `gcode_viewer/` directory bundles the following third-party libraries.
All licenses are compatible with Bambuddy's AGPL-3.0.
---
## PrettyGCode (OctoPrint plugin)
- **File:** `js/prettygcode.js`
- **Source:** https://github.com/Kragrathea/OctoPrint-PrettyGCode
- **License:** AGPLv3
---
## three.js
- **Files:** `js/three.min.js`, `js/OBJLoader.js`, `js/Line2.js`,
`js/LineGeometry.js`, `js/LineMaterial.js`, `js/LineSegments2.js`,
`js/LineSegmentsGeometry.js`, `js/Lut.js`
- **Version:** r108
- **Source:** https://github.com/mrdoob/three.js
- **License:** MIT — https://github.com/mrdoob/three.js/blob/dev/LICENSE
- **Note:** `OBJLoader`, `Line2`, `LineGeometry`, `LineMaterial`,
`LineSegments2`, `LineSegmentsGeometry`, and `Lut` are examples/extras
from three.js r108, same MIT licence.
---
## jQuery
- **File:** `js/jquery.min.js`
- **Version:** v3.7.1
- **Source:** https://github.com/jquery/jquery
- **License:** MIT — https://github.com/jquery/jquery/blob/main/LICENSE.txt
---
## dat.GUI
- **File:** `js/dat.gui.js`
- **Source:** https://github.com/dataarts/dat.gui
- **License:** Apache 2.0 — https://github.com/dataarts/dat.gui/blob/master/LICENSE
---
## camera-controls
- **File:** `js/camera-controls.js`
- **Source:** https://github.com/yomotsu/camera-controls
- **License:** MIT — https://github.com/yomotsu/camera-controls/blob/main/LICENSE
---
## Helvetiker Bold (typeface.js font)
- **File:** `js/helvetiker_bold.typeface.json`
- **Source:** Bundled with three.js examples; derived from M+ FONTS
- **License:** M+ Font License (free for any use including commercial)
https://mplus-fonts.osdn.jp/about-en.html
+417
View File
@@ -0,0 +1,417 @@
/* Dark mode color variables */
:root {
--pg-bg-light: #ffffff;
--pg-bg-dark: #1e1e1e;
--pg-text-light: #000000;
--pg-text-dark: #e0e0e0;
--pg-panel-light: rgba(255, 255, 255, 0.9);
--pg-panel-dark: rgba(30, 30, 30, 0.95);
--pg-border-light: #ddd;
--pg-border-dark: #444;
--pg-input-light: #e9e9e9;
--pg-input-dark: #2a2a2a;
}
#tab_plugin_prettygcode .gwin {
width: 100%;
height: 100%;
position: relative;
}
#tab_plugin_prettygcode .webcam_rotated {
transform: rotateZ(-90deg);
}
.pgfullscreen #tab_plugin_prettygcode .gwin {
top: 0px;
left: 0px;
width: 100%;
height: 100%;
position: absolute;
}
#tab_plugin_prettygcode .fstoggle {
top: 20px;
right: 60px;
position: absolute;
z-index: 10;
}
#tab_plugin_prettygcode .pgsettingstoggle {
top: 20px;
right: 95px;
position: absolute;
z-index: 10;
}
.pgfullscreen #tab_plugin_prettygcode .pgstatetoggle {
top: 20px;
left: 20px;
position: absolute;
z-index: 10;
display: unset;
}
#tab_plugin_prettygcode .pgstatetoggle {
display: none;
}
.pgfullscreen {
color: black;
}
.pgfullscreen #tab_plugin_prettygcode .pgfilestoggle {
top: 20px;
left: 95px;
position: absolute;
z-index: 10;
display: unset;
}
#tab_plugin_prettygcode .pgfilestoggle {
display: none;
}
.pgfullscreen #tab_plugin_prettygcode .pgcameratoggle {
bottom: 20px;
right: 40px;
position: absolute;
z-index: 10;
display: unset;
}
#tab_plugin_prettygcode .pgcameratoggle {
display: none;
}
.pgfullscreen .pgstatus {
display: unset;
}
.pgstatus {
display: none;
position: absolute;
top: 0px;
left: 0px;
width: 100%;
font-size: large;
text-align: center;
background: #ffffff50;
}
.pgfullscreen #state_wrapper {
top: 20px;
left: 20px;
width: 300px;
position: absolute;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
background: rgba(255, 255, 255, 0.2);
z-index: 5;
}
.pgfullscreen #files_wrapper {
left: 95px;
top: 20px;
max-width: 80%;
width: 300px;
position: absolute;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.70);
background: rgba(204, 204, 204, 0.9);
z-index: 6;
}
/* .pgfullscreen #files .gcode_files .entry {
padding: 5px;
line-height: 20px;
border-bottom: 1px solid #ddd;
position: relative;
width: 220px;
display: inline-grid;
background: white;
height: 100px;
} */
/* .pgfullscreen #files.collapse {
width:0px;
} */
.gwin #webcam_rotator {
bottom: 20px;
right: 40px;
position: absolute;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
z-index: 5;
display: none;
}
.pgfullscreen .gwin #webcam_rotator {
display: unset;
}
.pgfullscreen .gwin #webcam_rotator.pghidden {
display: none;
}
/* Hide notifications in fullscreen mode.*/
/* todo. only do this if not logged in or as admin.
.pgfullscreen .ui-notify {
display:none;
}
*/
.pgfullscreen .pghidden {
display: none;
}
.gwin .pghidden {
display: none;
}
/*dat gui*/
#tab_plugin_prettygcode #mygui {
position: absolute;
right: 95px;
top: 20px;
opacity: 1.0;
z-index: 5;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
z-index: 4;
background: rgba(255, 255, 255, 1.0);
opacity: 0.8;
}
.gwin .dg li.save-row {
background: black;
color: yellow;
}
.gwin .dg li.save-row .button {
background: black;
color: yellow;
}
.gwin .dg li.save-row select {
height: 100%;
}
.gwin .dg .close-button {
display: none;
}
.gwin .dg li.save-row .button.revert {
display: none;
}
.gwin .dg li.save-row .button.gears {
display: none;
}
.gwin .has-save .save-row::before {
content: "Preset: ";
}
.gwin .dg li.save-row {
display: none;
}
.gwin .dg.main.taller-than-window .close-button {
border-top: 1px solid #ddd;
}
.gwin .dg.main .close-button {
background-color: #ccc;
}
.gwin .dg.main .close-button:hover {
background-color: #ddd;
}
.gwin .dg {
color: #555;
text-shadow: none !important;
}
.gwin .dg.main::-webkit-scrollbar {
background: #fafafa;
}
.gwin .dg.main::-webkit-scrollbar-thumb {
background: #bbb;
}
.gwin .dg li:not(.folder) {
background: #fafafa;
border-bottom: 1px solid #ddd;
}
.gwin .dg li.save-row .button {
text-shadow: none !important;
}
.gwin .dg li.title {
background: #e8e8e8 url(data:image/gif;base64,R0lGODlhBQAFAJEAAP////Pz8////////yH5BAEAAAIALAAAAAAFAAUAAAIIlI+hKgFxoCgAOw==) 6px 10px no-repeat;
}
.gwin .dg .cr.function:hover,
.dg .cr.boolean:hover {
background: #fff;
}
.gwin .dg .c input[type=text] {
background: #e9e9e9;
}
.gwin .dg .c input[type=text]:hover {
background: #eee;
}
.gwin .dg .c input[type=text]:focus {
background: #eee;
color: #555;
}
.gwin .dg .c .slider {
background: #e9e9e9;
}
.gwin .dg .c .slider:hover {
background: #eee;
}
/*style slider to be more progress bar like*/
.gwin #myslider .slider-track {
background: green;
}
.gwin #myslider .slider-handle {
width: 24px;
}
.gwin #myslider .tooltip {
display: none;
}
#tab_plugin_prettygcode code {
border: 1px black;
white-space: pre;
}
/*Support Octoprint Dashboard plugin*/
.pgfullscreen #tab_plugin_dashboard {
display: unset;
position: absolute;
bottom: 20px;
left: 20px;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
z-index: 4;
background: rgba(255, 255, 255, 0.2);
opacity: 0.8;
/* width: 300px; */
/* transform: scale(0.8); */
}
.pgfullscreen #tab_plugin_prettygcode .pgdashtoggle {
left: 20px;
bottom: 20px;
position: absolute;
z-index: 10;
display: unset;
}
#tab_plugin_prettygcode .pgdashtoggle {
display: none;
}
.pgfullscreen #tab_plugin_dashboard.pghidden {
display: none;
}
/* Dark Mode Styles */
.pgdarkmode .pgfullscreen {
color: var(--pg-text-dark);
}
.pgdarkmode #state_wrapper {
background: var(--pg-panel-dark);
color: var(--pg-text-dark);
}
.pgdarkmode #files_wrapper {
background: var(--pg-panel-dark);
color: var(--pg-text-dark);
}
.pgdarkmode #tab_plugin_prettygcode #mygui {
background: var(--pg-panel-dark);
}
.pgdarkmode .gwin .dg {
color: var(--pg-text-dark);
}
.pgdarkmode .gwin .dg li:not(.folder) {
background: var(--pg-input-dark);
border-bottom: 1px solid var(--pg-border-dark);
}
.pgdarkmode .gwin .dg li.title {
background: #333 url(data:image/gif;base64,R0lGODlhBQAFAJEAAP////Pz8////////yH5BAEAAAIALAAAAAAFAAUAAAIIlI+hKgFxoCgAOw==) 6px 10px no-repeat;
}
.pgdarkmode .gwin .dg .cr.function:hover,
.pgdarkmode .gwin .dg .cr.boolean:hover {
background: #3a3a3a;
}
.pgdarkmode .gwin .dg .c input[type=text] {
background: var(--pg-input-dark);
color: var(--pg-text-dark);
}
.pgdarkmode .gwin .dg .c input[type=text]:hover {
background: #333;
}
.pgdarkmode .gwin .dg .c input[type=text]:focus {
background: #333;
color: var(--pg-text-dark);
}
.pgdarkmode .gwin .dg .c .slider {
background: var(--pg-input-dark);
}
.pgdarkmode .gwin .dg .c .slider:hover {
background: #333;
}
.pgdarkmode .gwin .dg.main .close-button {
background-color: #333;
}
.pgdarkmode .gwin .dg.main .close-button:hover {
background-color: #444;
}
.pgdarkmode .gwin .dg.main::-webkit-scrollbar {
background: #1e1e1e;
}
.pgdarkmode .gwin .dg.main::-webkit-scrollbar-thumb {
background: #555;
}
.pgdarkmode #tab_plugin_dashboard {
background: var(--pg-panel-dark);
color: var(--pg-text-dark);
}
.pgdarkmode .pgstatus {
background: rgba(30, 30, 30, 0.5);
color: var(--pg-text-dark);
}
+264
View File
@@ -0,0 +1,264 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- CSP: all scripts are local; unsafe-eval needed by Three.js shader compiler;
unsafe-inline needed by dat.GUI and jQuery for inline styles/event handlers -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' ws: wss:; font-src 'self' data:; worker-src blob:;">
<title>PrettyGCode — Bambuddy</title>
<link rel="stylesheet" href="css/prettygcode.css">
<style>
*, *::before, *::after { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
height: 100%;
background: #1a1a1a;
color: #e0e0e0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 14px;
}
/* Top toolbar */
#bb-toolbar {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 10px;
background: #111;
border-bottom: 1px solid #333;
flex-shrink: 0;
flex-wrap: wrap;
}
#bb-toolbar label { color: #aaa; font-size: 12px; white-space: nowrap; }
#bb-printer-select {
background: #222; color: #ddd;
border: 1px solid #444; border-radius: 4px;
padding: 3px 6px; font-size: 12px;
}
#bb-file-btn {
background: #2a6496; color: #fff;
border: none; border-radius: 4px;
padding: 4px 10px; cursor: pointer; font-size: 12px;
}
#bb-file-btn:hover { background: #1c4d72; }
#bb-current-file {
color: #9ecfff; font-size: 12px;
max-width: 300px; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap;
}
#bb-open-settings {
margin-left: auto;
background: none; color: #aaa;
border: 1px solid #555; border-radius: 4px;
padding: 3px 8px; cursor: pointer; font-size: 12px;
}
#bb-open-settings:hover { color: #fff; border-color: #888; }
/* Playback controls */
#bb-play-btn {
background: #2a7a4a; color: #fff;
border: none; border-radius: 4px;
padding: 4px 10px; cursor: pointer; font-size: 14px;
min-width: 34px;
}
#bb-play-btn:hover { background: #1d5c38; }
#bb-play-btn:disabled { background: #444; cursor: default; }
#bb-play-speed {
background: #222; color: #ddd;
border: 1px solid #444; border-radius: 4px;
padding: 3px 5px; font-size: 12px;
}
/* File picker dropdown */
#bb-file-picker {
display: none;
position: fixed; /* fixed so it's never clipped by viewer overflow */
top: 38px; left: 10px;
width: 320px;
background: #222; border: 1px solid #444;
border-radius: 6px; padding: 6px;
z-index: 9999;
box-shadow: 0 4px 16px rgba(0,0,0,0.6);
}
#bb-file-picker.bb-open { display: block; }
/* Main layout */
#bb-layout {
display: flex;
flex-direction: column;
height: 100vh;
}
#bb-viewer-wrap {
flex: 1;
position: relative;
overflow: hidden;
}
/* The prettygcode container — must fill its parent */
.page-container {
width: 100%;
height: 100%;
position: relative;
}
#tab_plugin_prettygcode {
width: 100%;
height: 100%;
}
.gwin {
width: 100%;
height: 100%;
position: relative;
}
#mycanvas {
width: 100%;
height: 100%;
display: block;
}
/* Webcam element cloned by prettygcode.js — keep hidden until fullscreen */
#webcam_rotator {
display: none;
}
#webcam_image { max-width: 100%; }
/* Control buttons inside .gwin */
.pgstatetoggle, .pgfilestoggle, .pgsettingstoggle, .fstoggle,
.pgdashtoggle, .pgcameratoggle {
background: rgba(0,0,0,0.5);
border: 1px solid #666;
border-radius: 4px;
color: #ddd;
cursor: pointer;
font-size: 16px;
padding: 4px 7px;
z-index: 10;
}
.pgstatetoggle:hover, .pgfilestoggle:hover, .pgsettingstoggle:hover,
.fstoggle:hover, .pgdashtoggle:hover, .pgcameratoggle:hover {
background: rgba(60,60,60,0.8);
}
/* Slider shim styles */
.slider {
position: absolute;
right: 20px;
top: 5%;
height: 90%;
width: 28px;
z-index: 10;
cursor: pointer;
user-select: none;
}
.slider-vertical { writing-mode: vertical-lr; }
.slider-track {
position: absolute;
left: 50%;
top: 0; bottom: 0;
width: 4px;
transform: translateX(-50%);
background: #555;
border-radius: 2px;
}
.slider-selection {
position: absolute;
left: 0; right: 0;
bottom: 0;
background: #4a9;
border-radius: 2px;
}
.slider-handle {
position: absolute;
left: 50%;
transform: translateX(-50%);
width: 24px; height: 24px;
background: #6cf;
border-radius: 50%;
line-height: 24px;
text-align: center;
font-size: 9px;
color: #111;
font-weight: bold;
}
</style>
</head>
<body>
<div id="bb-layout">
<!-- Toolbar -->
<div id="bb-toolbar">
<label for="bb-printer-select">Printer:</label>
<select id="bb-printer-select"><option value="">Loading…</option></select>
<button id="bb-file-btn">&#128196; Load file</button>
<span id="bb-current-file">— no file loaded —</span>
<button id="bb-play-btn" title="Play layer animation" disabled>&#9654;</button>
<select id="bb-play-speed" title="Playback speed">
<option value="1">1&#215; slow</option>
<option value="3" selected>3&#215;</option>
<option value="10">10&#215; fast</option>
<option value="25">25&#215; turbo</option>
</select>
</div>
<!-- File picker (positioned relative to toolbar) -->
<div id="bb-file-picker"></div>
<!-- Viewer area -->
<div id="bb-viewer-wrap">
<div class="page-container">
<div id="tab_plugin_prettygcode">
<div class="gwin">
<canvas id="mycanvas"></canvas>
<div id="pgstatus" class="pgstatus">T:0/0 B:0/0</div>
<div id="mygui" class="pghidden">View Options</div>
<button class="pgstatetoggle" title="Toggle state window">&#9432;</button>
<button class="pgfilestoggle" title="Toggle file list">&#9776;</button>
<button class="pgsettingstoggle" title="Toggle settings">&#9881;</button>
<button class="pgcameratoggle" title="Toggle webcam">&#128247;</button>
</div>
</div>
<!-- Webcam source element — prettygcode.js clones this into .gwin -->
<div id="webcam_rotator" style="display:none;">
<img id="webcam_image" src="" alt="webcam">
</div>
</div>
</div><!-- /bb-viewer-wrap -->
</div><!-- /bb-layout -->
<!-- Scripts — load order matters -->
<script src="js/jquery.min.js"></script>
<script src="js/slider-shim.js"></script>
<script src="js/three.min.js"></script>
<script src="js/LineSegmentsGeometry.js"></script>
<script src="js/LineGeometry.js"></script>
<script src="js/LineMaterial.js"></script>
<script src="js/LineSegments2.js"></script>
<script src="js/Line2.js"></script>
<script src="js/Lut.js"></script>
<script src="js/OBJLoader.js"></script>
<script src="js/camera-controls.js"></script>
<script src="js/dat.gui.js"></script>
<!-- Adapter MUST load before prettygcode.js -->
<script src="js/bambuddy_adapter.js"></script>
<script src="js/prettygcode.js"></script>
<!-- Button wiring is in bambuddy_adapter.js — inline scripts are blocked by the
script-src CSP on this page (no 'unsafe-inline'). -->
</body>
</html>
+31
View File
@@ -0,0 +1,31 @@
/**
* @author WestLangley / http://github.com/WestLangley
*
*/
THREE.Line2 = function ( geometry, material ) {
THREE.LineSegments2.call( this );
this.type = 'Line2';
this.geometry = geometry !== undefined ? geometry : new THREE.LineGeometry();
this.material = material !== undefined ? material : new THREE.LineMaterial( { color: Math.random() * 0xffffff } );
};
THREE.Line2.prototype = Object.assign( Object.create( THREE.LineSegments2.prototype ), {
constructor: THREE.Line2,
isLine2: true,
copy: function ( /* source */ ) {
// todo
return this;
}
} );
+104
View File
@@ -0,0 +1,104 @@
/**
* @author WestLangley / http://github.com/WestLangley
*
*/
THREE.LineGeometry = function () {
THREE.LineSegmentsGeometry.call( this );
this.type = 'LineGeometry';
};
THREE.LineGeometry.prototype = Object.assign(Object.create(THREE.LineSegmentsGeometry.prototype), {
constructor: THREE.LineGeometry,
isLineGeometry: true,
setPositions: function (array, unpack = false ) {
// converts [ x1, y1, z1, x2, y2, z2, ... ] to pairs format
if (unpack) {
var length = array.length - 3;
var points = new Float32Array(2 * length);
for (var i = 0; i < length; i += 3) {
points[2 * i] = array[i];
points[2 * i + 1] = array[i + 1];
points[2 * i + 2] = array[i + 2];
points[2 * i + 3] = array[i + 3];
points[2 * i + 4] = array[i + 4];
points[2 * i + 5] = array[i + 5];
}
THREE.LineSegmentsGeometry.prototype.setPositions.call(this, points);
}
else
THREE.LineSegmentsGeometry.prototype.setPositions.call(this, array);
return this;
},
setColors: function (array, unpack = false ) {
// converts [ r1, g1, b1, r2, g2, b2, ... ] to pairs format
if (unpack) {
var length = array.length - 3;
var colors = new Float32Array( 2 * length );
for ( var i = 0; i < length; i += 3 ) {
colors[ 2 * i ] = array[ i ];
colors[ 2 * i + 1 ] = array[ i + 1 ];
colors[ 2 * i + 2 ] = array[ i + 2 ];
colors[ 2 * i + 3 ] = array[ i + 3 ];
colors[ 2 * i + 4 ] = array[ i + 4 ];
colors[ 2 * i + 5 ] = array[ i + 5 ];
}
THREE.LineSegmentsGeometry.prototype.setColors.call( this, colors );
}
else
THREE.LineSegmentsGeometry.prototype.setColors.call(this, array);
return this;
},
fromLine: function ( line ) {
var geometry = line.geometry;
if ( geometry.isGeometry ) {
this.setPositions( geometry.vertices );
} else if ( geometry.isBufferGeometry ) {
this.setPositions( geometry.position.array ); // assumes non-indexed
}
// set colors, maybe
return this;
},
copy: function ( /* source */ ) {
// todo
return this;
}
} );
+391
View File
@@ -0,0 +1,391 @@
/**
* @author WestLangley / http://github.com/WestLangley
*
* parameters = {
* color: <hex>,
* linewidth: <float>,
* dashed: <boolean>,
* dashScale: <float>,
* dashSize: <float>,
* gapSize: <float>,
* resolution: <Vector2>, // to be set by renderer
* }
*/
THREE.UniformsLib.line = {
linewidth: { value: 1 },
resolution: { value: new THREE.Vector2( 1, 1 ) },
dashScale: { value: 1 },
dashSize: { value: 1 },
gapSize: { value: 1 } // todo FIX - maybe change to totalSize
};
THREE.ShaderLib[ 'line' ] = {
uniforms: THREE.UniformsUtils.merge( [
THREE.UniformsLib.common,
THREE.UniformsLib.fog,
THREE.UniformsLib.line
] ),
vertexShader:
`
#include <common>
#include <color_pars_vertex>
#include <fog_pars_vertex>
#include <logdepthbuf_pars_vertex>
#include <clipping_planes_pars_vertex>
uniform float linewidth;
uniform vec2 resolution;
attribute vec3 instanceStart;
attribute vec3 instanceEnd;
attribute vec3 instanceColorStart;
attribute vec3 instanceColorEnd;
varying vec2 vUv;
#ifdef USE_DASH
uniform float dashScale;
attribute float instanceDistanceStart;
attribute float instanceDistanceEnd;
varying float vLineDistance;
#endif
void trimSegment( const in vec4 start, inout vec4 end ) {
// trim end segment so it terminates between the camera plane and the near plane
// conservative estimate of the near plane
float a = projectionMatrix[ 2 ][ 2 ]; // 3nd entry in 3th column
float b = projectionMatrix[ 3 ][ 2 ]; // 3nd entry in 4th column
float nearEstimate = - 0.5 * b / a;
float alpha = ( nearEstimate - start.z ) / ( end.z - start.z );
end.xyz = mix( start.xyz, end.xyz, alpha );
}
void main() {
#ifdef USE_COLOR
vColor.xyz = ( position.y < 0.5 ) ? instanceColorStart : instanceColorEnd;
#endif
#ifdef USE_DASH
vLineDistance = ( position.y < 0.5 ) ? dashScale * instanceDistanceStart : dashScale * instanceDistanceEnd;
#endif
float aspect = resolution.x / resolution.y;
vUv = uv;
// camera space
vec4 start = modelViewMatrix * vec4( instanceStart, 1.0 );
vec4 end = modelViewMatrix * vec4( instanceEnd, 1.0 );
// special case for perspective projection, and segments that terminate either in, or behind, the camera plane
// clearly the gpu firmware has a way of addressing this issue when projecting into ndc space
// but we need to perform ndc-space calculations in the shader, so we must address this issue directly
// perhaps there is a more elegant solution -- WestLangley
bool perspective = ( projectionMatrix[ 2 ][ 3 ] == - 1.0 ); // 4th entry in the 3rd column
if ( perspective ) {
if ( start.z < 0.0 && end.z >= 0.0 ) {
trimSegment( start, end );
} else if ( end.z < 0.0 && start.z >= 0.0 ) {
trimSegment( end, start );
}
}
// clip space
vec4 clipStart = projectionMatrix * start;
vec4 clipEnd = projectionMatrix * end;
// ndc space
vec2 ndcStart = clipStart.xy / clipStart.w;
vec2 ndcEnd = clipEnd.xy / clipEnd.w;
// direction
vec2 dir = ndcEnd - ndcStart;
// account for clip-space aspect ratio
dir.x *= aspect;
dir = normalize( dir );
// perpendicular to dir
vec2 offset = vec2( dir.y, - dir.x );
// undo aspect ratio adjustment
dir.x /= aspect;
offset.x /= aspect;
// sign flip
if ( position.x < 0.0 ) offset *= - 1.0;
// endcaps
if ( position.y < 0.0 ) {
offset += - dir;
} else if ( position.y > 1.0 ) {
offset += dir;
}
// adjust for linewidth
offset *= linewidth;
// adjust for clip-space to screen-space conversion // maybe resolution should be based on viewport ...
offset /= resolution.y;
// select end
vec4 clip = ( position.y < 0.5 ) ? clipStart : clipEnd;
// back to clip space
offset *= clip.w;
clip.xy += offset;
gl_Position = clip;
vec4 mvPosition = ( position.y < 0.5 ) ? start : end; // this is an approximation
#include <logdepthbuf_vertex>
#include <clipping_planes_vertex>
#include <fog_vertex>
}
`,
fragmentShader:
`
uniform vec3 diffuse;
uniform float opacity;
#ifdef USE_DASH
uniform float dashSize;
uniform float gapSize;
#endif
varying float vLineDistance;
#include <common>
#include <color_pars_fragment>
#include <fog_pars_fragment>
#include <logdepthbuf_pars_fragment>
#include <clipping_planes_pars_fragment>
varying vec2 vUv;
void main() {
#include <clipping_planes_fragment>
#ifdef USE_DASH
if ( vUv.y < - 1.0 || vUv.y > 1.0 ) discard; // discard endcaps
if ( mod( vLineDistance, dashSize + gapSize ) > dashSize ) discard; // todo - FIX
#endif
if ( abs( vUv.y ) > 1.0 ) {
float a = vUv.x;
float b = ( vUv.y > 0.0 ) ? vUv.y - 1.0 : vUv.y + 1.0;
float len2 = a * a + b * b;
if ( len2 > 1.0 ) discard;
}
vec4 diffuseColor = vec4( diffuse, opacity );
#include <logdepthbuf_fragment>
#include <color_fragment>
gl_FragColor = vec4( diffuseColor.rgb, diffuseColor.a );
#include <premultiplied_alpha_fragment>
#include <tonemapping_fragment>
#include <encodings_fragment>
#include <fog_fragment>
}
`
};
THREE.LineMaterial = function ( parameters ) {
THREE.ShaderMaterial.call( this, {
type: 'LineMaterial',
uniforms: THREE.UniformsUtils.clone( THREE.ShaderLib[ 'line' ].uniforms ),
vertexShader: THREE.ShaderLib[ 'line' ].vertexShader,
fragmentShader: THREE.ShaderLib[ 'line' ].fragmentShader
} );
this.dashed = false;
Object.defineProperties( this, {
color: {
enumerable: true,
get: function () {
return this.uniforms.diffuse.value;
},
set: function ( value ) {
this.uniforms.diffuse.value = value;
}
},
linewidth: {
enumerable: true,
get: function () {
return this.uniforms.linewidth.value;
},
set: function ( value ) {
this.uniforms.linewidth.value = value;
}
},
dashScale: {
enumerable: true,
get: function () {
return this.uniforms.dashScale.value;
},
set: function ( value ) {
this.uniforms.dashScale.value = value;
}
},
dashSize: {
enumerable: true,
get: function () {
return this.uniforms.dashSize.value;
},
set: function ( value ) {
this.uniforms.dashSize.value = value;
}
},
gapSize: {
enumerable: true,
get: function () {
return this.uniforms.gapSize.value;
},
set: function ( value ) {
this.uniforms.gapSize.value = value;
}
},
resolution: {
enumerable: true,
get: function () {
return this.uniforms.resolution.value;
},
set: function ( value ) {
this.uniforms.resolution.value.copy( value );
}
}
} );
this.setValues( parameters );
};
THREE.LineMaterial.prototype = Object.create( THREE.ShaderMaterial.prototype );
THREE.LineMaterial.prototype.constructor = THREE.LineMaterial;
THREE.LineMaterial.prototype.isLineMaterial = true;
THREE.LineMaterial.prototype.copy = function ( source ) {
THREE.ShaderMaterial.prototype.copy.call( this, source );
this.color.copy( source.color );
this.linewidth = source.linewidth;
this.resolution = source.resolution;
// todo
return this;
};
+65
View File
@@ -0,0 +1,65 @@
/**
* @author WestLangley / http://github.com/WestLangley
*
*/
THREE.LineSegments2 = function ( geometry, material ) {
THREE.Mesh.call( this );
this.type = 'LineSegments2';
this.geometry = geometry !== undefined ? geometry : new THREE.LineSegmentsGeometry();
this.material = material !== undefined ? material : new THREE.LineMaterial( { color: Math.random() * 0xffffff } );
};
THREE.LineSegments2.prototype = Object.assign( Object.create( THREE.Mesh.prototype ), {
constructor: THREE.LineSegments2,
isLineSegments2: true,
computeLineDistances: ( function () { // for backwards-compatability, but could be a method of LineSegmentsGeometry...
var start = new THREE.Vector3();
var end = new THREE.Vector3();
return function computeLineDistances() {
var geometry = this.geometry;
var instanceStart = geometry.attributes.instanceStart;
var instanceEnd = geometry.attributes.instanceEnd;
var lineDistances = new Float32Array( 2 * instanceStart.data.count );
for ( var i = 0, j = 0, l = instanceStart.data.count; i < l; i ++, j += 2 ) {
start.fromBufferAttribute( instanceStart, i );
end.fromBufferAttribute( instanceEnd, i );
lineDistances[ j ] = ( j === 0 ) ? 0 : lineDistances[ j - 1 ];
lineDistances[ j + 1 ] = lineDistances[ j ] + start.distanceTo( end );
}
var instanceDistanceBuffer = new THREE.InstancedInterleavedBuffer( lineDistances, 2, 1 ); // d0, d1
geometry.addAttribute( 'instanceDistanceStart', new THREE.InterleavedBufferAttribute( instanceDistanceBuffer, 1, 0 ) ); // d0
geometry.addAttribute( 'instanceDistanceEnd', new THREE.InterleavedBufferAttribute( instanceDistanceBuffer, 1, 1 ) ); // d1
return this;
};
}() ),
copy: function ( /* source */ ) {
// todo
return this;
}
} );
+258
View File
@@ -0,0 +1,258 @@
/**
* @author WestLangley / http://github.com/WestLangley
*
*/
THREE.LineSegmentsGeometry = function () {
THREE.InstancedBufferGeometry.call( this );
this.type = 'LineSegmentsGeometry';
var positions = [ - 1, 2, 0, 1, 2, 0, - 1, 1, 0, 1, 1, 0, - 1, 0, 0, 1, 0, 0, - 1, - 1, 0, 1, - 1, 0 ];
var uvs = [ - 1, 2, 1, 2, - 1, 1, 1, 1, - 1, - 1, 1, - 1, - 1, - 2, 1, - 2 ];
var index = [ 0, 2, 1, 2, 3, 1, 2, 4, 3, 4, 5, 3, 4, 6, 5, 6, 7, 5 ];
this.setIndex( index );
this.addAttribute( 'position', new THREE.Float32BufferAttribute( positions, 3 ) );
this.addAttribute( 'uv', new THREE.Float32BufferAttribute( uvs, 2 ) );
};
THREE.LineSegmentsGeometry.prototype = Object.assign( Object.create( THREE.InstancedBufferGeometry.prototype ), {
constructor: THREE.LineSegmentsGeometry,
isLineSegmentsGeometry: true,
applyMatrix: function ( matrix ) {
var start = this.attributes.instanceStart;
var end = this.attributes.instanceEnd;
if ( start !== undefined ) {
matrix.applyToBufferAttribute( start );
matrix.applyToBufferAttribute( end );
start.data.needsUpdate = true;
}
if ( this.boundingBox !== null ) {
this.computeBoundingBox();
}
if ( this.boundingSphere !== null ) {
this.computeBoundingSphere();
}
return this;
},
setPositions: function ( array ) {
var lineSegments;
if ( array instanceof Float32Array ) {
lineSegments = array;
} else if ( Array.isArray( array ) ) {
lineSegments = new Float32Array( array );
}
var instanceBuffer = new THREE.InstancedInterleavedBuffer( lineSegments, 6, 1 ); // xyz, xyz
this.addAttribute( 'instanceStart', new THREE.InterleavedBufferAttribute( instanceBuffer, 3, 0 ) ); // xyz
this.addAttribute( 'instanceEnd', new THREE.InterleavedBufferAttribute( instanceBuffer, 3, 3 ) ); // xyz
//
this.computeBoundingBox();
this.computeBoundingSphere();
return this;
},
setColors: function ( array ) {
var colors;
if ( array instanceof Float32Array ) {
colors = array;
} else if ( Array.isArray( array ) ) {
colors = new Float32Array( array );
}
var instanceColorBuffer = new THREE.InstancedInterleavedBuffer( colors, 6, 1 ); // rgb, rgb
this.addAttribute( 'instanceColorStart', new THREE.InterleavedBufferAttribute( instanceColorBuffer, 3, 0 ) ); // rgb
this.addAttribute( 'instanceColorEnd', new THREE.InterleavedBufferAttribute( instanceColorBuffer, 3, 3 ) ); // rgb
return this;
},
fromWireframeGeometry: function ( geometry ) {
this.setPositions( geometry.attributes.position.array );
return this;
},
fromEdgesGeometry: function ( geometry ) {
this.setPositions( geometry.attributes.position.array );
return this;
},
fromMesh: function ( mesh ) {
this.fromWireframeGeometry( new THREE.WireframeGeometry( mesh.geometry ) );
// set colors, maybe
return this;
},
fromLineSegements: function ( lineSegments ) {
var geometry = lineSegments.geometry;
if ( geometry.isGeometry ) {
this.setPositions( geometry.vertices );
} else if ( geometry.isBufferGeometry ) {
this.setPositions( geometry.position.array ); // assumes non-indexed
}
// set colors, maybe
return this;
},
computeBoundingBox: function () {
var box = new THREE.Box3();
return function computeBoundingBox() {
if ( this.boundingBox === null ) {
this.boundingBox = new THREE.Box3();
}
var start = this.attributes.instanceStart;
var end = this.attributes.instanceEnd;
if ( start !== undefined && end !== undefined ) {
this.boundingBox.setFromBufferAttribute( start );
box.setFromBufferAttribute( end );
this.boundingBox.union( box );
}
};
}(),
computeBoundingSphere: function () {
var vector = new THREE.Vector3();
return function computeBoundingSphere() {
if ( this.boundingSphere === null ) {
this.boundingSphere = new THREE.Sphere();
}
if ( this.boundingBox === null ) {
this.computeBoundingBox();
}
var start = this.attributes.instanceStart;
var end = this.attributes.instanceEnd;
if ( start !== undefined && end !== undefined ) {
var center = this.boundingSphere.center;
this.boundingBox.getCenter( center );
var maxRadiusSq = 0;
for ( var i = 0, il = start.count; i < il; i ++ ) {
vector.fromBufferAttribute( start, i );
maxRadiusSq = Math.max( maxRadiusSq, center.distanceToSquared( vector ) );
vector.fromBufferAttribute( end, i );
maxRadiusSq = Math.max( maxRadiusSq, center.distanceToSquared( vector ) );
}
this.boundingSphere.radius = Math.sqrt( maxRadiusSq );
if ( isNaN( this.boundingSphere.radius ) ) {
console.error( 'THREE.LineSegmentsGeometry.computeBoundingSphere(): Computed radius is NaN. The instanced position data is likely to have NaN values.', this );
}
}
};
}(),
toJSON: function () {
// todo
},
clone: function () {
// todo
},
copy: function ( /* source */ ) {
// todo
return this;
}
} );
+185
View File
@@ -0,0 +1,185 @@
/**
* @author daron1337 / http://daron1337.github.io/
*/
THREE.Lut = function ( colormap, numberofcolors ) {
this.lut = [];
this.setColorMap( colormap, numberofcolors );
return this;
};
THREE.Lut.prototype = {
constructor: THREE.Lut,
lut: [], map: [], n: 256, minV: 0, maxV: 1,
set: function ( value ) {
if ( value instanceof THREE.Lut ) {
this.copy( value );
}
return this;
},
setMin: function ( min ) {
this.minV = min;
return this;
},
setMax: function ( max ) {
this.maxV = max;
return this;
},
setColorMap: function ( colormap, numberofcolors ) {
this.map = THREE.ColorMapKeywords[ colormap ] || THREE.ColorMapKeywords.rainbow;
this.n = numberofcolors || 32;
var step = 1.0 / this.n;
this.lut.length = 0;
for ( var i = 0; i <= 1; i += step ) {
for ( var j = 0; j < this.map.length - 1; j ++ ) {
if ( i >= this.map[ j ][ 0 ] && i < this.map[ j + 1 ][ 0 ] ) {
var min = this.map[ j ][ 0 ];
var max = this.map[ j + 1 ][ 0 ];
var minColor = new THREE.Color( this.map[ j ][ 1 ] );
var maxColor = new THREE.Color( this.map[ j + 1 ][ 1 ] );
var color = minColor.lerp( maxColor, ( i - min ) / ( max - min ) );
this.lut.push( color );
}
}
}
return this;
},
copy: function ( lut ) {
this.lut = lut.lut;
this.map = lut.map;
this.n = lut.n;
this.minV = lut.minV;
this.maxV = lut.maxV;
return this;
},
getColor: function ( alpha ) {
if ( alpha <= this.minV ) {
alpha = this.minV;
} else if ( alpha >= this.maxV ) {
alpha = this.maxV;
}
alpha = ( alpha - this.minV ) / ( this.maxV - this.minV );
var colorPosition = Math.round( alpha * this.n );
colorPosition == this.n ? colorPosition -= 1 : colorPosition;
return this.lut[ colorPosition ];
},
addColorMap: function ( colormapName, arrayOfColors ) {
THREE.ColorMapKeywords[ colormapName ] = arrayOfColors;
},
createCanvas: function () {
var canvas = document.createElement( 'canvas' );
canvas.width = 1;
canvas.height = this.n;
this.updateCanvas( canvas );
return canvas;
},
updateCanvas: function ( canvas ) {
var ctx = canvas.getContext( '2d', { alpha: false } );
var imageData = ctx.getImageData( 0, 0, 1, this.n );
var data = imageData.data;
var k = 0;
var step = 1.0 / this.n;
for ( var i = 1; i >= 0; i -= step ) {
for ( var j = this.map.length - 1; j >= 0; j -- ) {
if ( i < this.map[ j ][ 0 ] && i >= this.map[ j - 1 ][ 0 ] ) {
var min = this.map[ j - 1 ][ 0 ];
var max = this.map[ j ][ 0 ];
var minColor = new THREE.Color( this.map[ j - 1 ][ 1 ] );
var maxColor = new THREE.Color( this.map[ j ][ 1 ] );
var color = minColor.lerp( maxColor, ( i - min ) / ( max - min ) );
data[ k * 4 ] = Math.round( color.r * 255 );
data[ k * 4 + 1 ] = Math.round( color.g * 255 );
data[ k * 4 + 2 ] = Math.round( color.b * 255 );
data[ k * 4 + 3 ] = 255;
k += 1;
}
}
}
ctx.putImageData( imageData, 0, 0 );
return canvas;
}
};
THREE.ColorMapKeywords = {
"rainbow": [[ 0.0, 0x0000FF ], [ 0.2, 0x00FFFF ], [ 0.5, 0x00FF00 ], [ 0.8, 0xFFFF00 ], [ 1.0, 0xFF0000 ]],
"cooltowarm": [[ 0.0, 0x3C4EC2 ], [ 0.2, 0x9BBCFF ], [ 0.5, 0xDCDCDC ], [ 0.8, 0xF6A385 ], [ 1.0, 0xB40426 ]],
"blackbody": [[ 0.0, 0x000000 ], [ 0.2, 0x780000 ], [ 0.5, 0xE63200 ], [ 0.8, 0xFFFF00 ], [ 1.0, 0xFFFFFF ]],
"grayscale": [[ 0.0, 0x000000 ], [ 0.2, 0x404040 ], [ 0.5, 0x7F7F80 ], [ 0.8, 0xBFBFBF ], [ 1.0, 0xFFFFFF ]]
};
+797
View File
@@ -0,0 +1,797 @@
/**
* @author mrdoob / http://mrdoob.com/
*/
THREE.OBJLoader = ( function () {
// o object_name | g group_name
var object_pattern = /^[og]\s*(.+)?/;
// mtllib file_reference
var material_library_pattern = /^mtllib /;
// usemtl material_name
var material_use_pattern = /^usemtl /;
function ParserState() {
var state = {
objects: [],
object: {},
vertices: [],
normals: [],
colors: [],
uvs: [],
materialLibraries: [],
startObject: function ( name, fromDeclaration ) {
// If the current object (initial from reset) is not from a g/o declaration in the parsed
// file. We need to use it for the first parsed g/o to keep things in sync.
if ( this.object && this.object.fromDeclaration === false ) {
this.object.name = name;
this.object.fromDeclaration = ( fromDeclaration !== false );
return;
}
var previousMaterial = ( this.object && typeof this.object.currentMaterial === 'function' ? this.object.currentMaterial() : undefined );
if ( this.object && typeof this.object._finalize === 'function' ) {
this.object._finalize( true );
}
this.object = {
name: name || '',
fromDeclaration: ( fromDeclaration !== false ),
geometry: {
vertices: [],
normals: [],
colors: [],
uvs: []
},
materials: [],
smooth: true,
startMaterial: function ( name, libraries ) {
var previous = this._finalize( false );
// New usemtl declaration overwrites an inherited material, except if faces were declared
// after the material, then it must be preserved for proper MultiMaterial continuation.
if ( previous && ( previous.inherited || previous.groupCount <= 0 ) ) {
this.materials.splice( previous.index, 1 );
}
var material = {
index: this.materials.length,
name: name || '',
mtllib: ( Array.isArray( libraries ) && libraries.length > 0 ? libraries[ libraries.length - 1 ] : '' ),
smooth: ( previous !== undefined ? previous.smooth : this.smooth ),
groupStart: ( previous !== undefined ? previous.groupEnd : 0 ),
groupEnd: - 1,
groupCount: - 1,
inherited: false,
clone: function ( index ) {
var cloned = {
index: ( typeof index === 'number' ? index : this.index ),
name: this.name,
mtllib: this.mtllib,
smooth: this.smooth,
groupStart: 0,
groupEnd: - 1,
groupCount: - 1,
inherited: false
};
cloned.clone = this.clone.bind( cloned );
return cloned;
}
};
this.materials.push( material );
return material;
},
currentMaterial: function () {
if ( this.materials.length > 0 ) {
return this.materials[ this.materials.length - 1 ];
}
return undefined;
},
_finalize: function ( end ) {
var lastMultiMaterial = this.currentMaterial();
if ( lastMultiMaterial && lastMultiMaterial.groupEnd === - 1 ) {
lastMultiMaterial.groupEnd = this.geometry.vertices.length / 3;
lastMultiMaterial.groupCount = lastMultiMaterial.groupEnd - lastMultiMaterial.groupStart;
lastMultiMaterial.inherited = false;
}
// Ignore objects tail materials if no face declarations followed them before a new o/g started.
if ( end && this.materials.length > 1 ) {
for ( var mi = this.materials.length - 1; mi >= 0; mi -- ) {
if ( this.materials[ mi ].groupCount <= 0 ) {
this.materials.splice( mi, 1 );
}
}
}
// Guarantee at least one empty material, this makes the creation later more straight forward.
if ( end && this.materials.length === 0 ) {
this.materials.push( {
name: '',
smooth: this.smooth
} );
}
return lastMultiMaterial;
}
};
// Inherit previous objects material.
// Spec tells us that a declared material must be set to all objects until a new material is declared.
// If a usemtl declaration is encountered while this new object is being parsed, it will
// overwrite the inherited material. Exception being that there was already face declarations
// to the inherited material, then it will be preserved for proper MultiMaterial continuation.
if ( previousMaterial && previousMaterial.name && typeof previousMaterial.clone === 'function' ) {
var declared = previousMaterial.clone( 0 );
declared.inherited = true;
this.object.materials.push( declared );
}
this.objects.push( this.object );
},
finalize: function () {
if ( this.object && typeof this.object._finalize === 'function' ) {
this.object._finalize( true );
}
},
parseVertexIndex: function ( value, len ) {
var index = parseInt( value, 10 );
return ( index >= 0 ? index - 1 : index + len / 3 ) * 3;
},
parseNormalIndex: function ( value, len ) {
var index = parseInt( value, 10 );
return ( index >= 0 ? index - 1 : index + len / 3 ) * 3;
},
parseUVIndex: function ( value, len ) {
var index = parseInt( value, 10 );
return ( index >= 0 ? index - 1 : index + len / 2 ) * 2;
},
addVertex: function ( a, b, c ) {
var src = this.vertices;
var dst = this.object.geometry.vertices;
dst.push( src[ a + 0 ], src[ a + 1 ], src[ a + 2 ] );
dst.push( src[ b + 0 ], src[ b + 1 ], src[ b + 2 ] );
dst.push( src[ c + 0 ], src[ c + 1 ], src[ c + 2 ] );
},
addVertexPoint: function ( a ) {
var src = this.vertices;
var dst = this.object.geometry.vertices;
dst.push( src[ a + 0 ], src[ a + 1 ], src[ a + 2 ] );
},
addVertexLine: function ( a ) {
var src = this.vertices;
var dst = this.object.geometry.vertices;
dst.push( src[ a + 0 ], src[ a + 1 ], src[ a + 2 ] );
},
addNormal: function ( a, b, c ) {
var src = this.normals;
var dst = this.object.geometry.normals;
dst.push( src[ a + 0 ], src[ a + 1 ], src[ a + 2 ] );
dst.push( src[ b + 0 ], src[ b + 1 ], src[ b + 2 ] );
dst.push( src[ c + 0 ], src[ c + 1 ], src[ c + 2 ] );
},
addColor: function ( a, b, c ) {
var src = this.colors;
var dst = this.object.geometry.colors;
dst.push( src[ a + 0 ], src[ a + 1 ], src[ a + 2 ] );
dst.push( src[ b + 0 ], src[ b + 1 ], src[ b + 2 ] );
dst.push( src[ c + 0 ], src[ c + 1 ], src[ c + 2 ] );
},
addUV: function ( a, b, c ) {
var src = this.uvs;
var dst = this.object.geometry.uvs;
dst.push( src[ a + 0 ], src[ a + 1 ] );
dst.push( src[ b + 0 ], src[ b + 1 ] );
dst.push( src[ c + 0 ], src[ c + 1 ] );
},
addUVLine: function ( a ) {
var src = this.uvs;
var dst = this.object.geometry.uvs;
dst.push( src[ a + 0 ], src[ a + 1 ] );
},
addFace: function ( a, b, c, ua, ub, uc, na, nb, nc ) {
var vLen = this.vertices.length;
var ia = this.parseVertexIndex( a, vLen );
var ib = this.parseVertexIndex( b, vLen );
var ic = this.parseVertexIndex( c, vLen );
this.addVertex( ia, ib, ic );
if ( ua !== undefined && ua !== '' ) {
var uvLen = this.uvs.length;
ia = this.parseUVIndex( ua, uvLen );
ib = this.parseUVIndex( ub, uvLen );
ic = this.parseUVIndex( uc, uvLen );
this.addUV( ia, ib, ic );
}
if ( na !== undefined && na !== '' ) {
// Normals are many times the same. If so, skip function call and parseInt.
var nLen = this.normals.length;
ia = this.parseNormalIndex( na, nLen );
ib = na === nb ? ia : this.parseNormalIndex( nb, nLen );
ic = na === nc ? ia : this.parseNormalIndex( nc, nLen );
this.addNormal( ia, ib, ic );
}
if ( this.colors.length > 0 ) {
this.addColor( ia, ib, ic );
}
},
addPointGeometry: function ( vertices ) {
this.object.geometry.type = 'Points';
var vLen = this.vertices.length;
for ( var vi = 0, l = vertices.length; vi < l; vi ++ ) {
this.addVertexPoint( this.parseVertexIndex( vertices[ vi ], vLen ) );
}
},
addLineGeometry: function ( vertices, uvs ) {
this.object.geometry.type = 'Line';
var vLen = this.vertices.length;
var uvLen = this.uvs.length;
for ( var vi = 0, l = vertices.length; vi < l; vi ++ ) {
this.addVertexLine( this.parseVertexIndex( vertices[ vi ], vLen ) );
}
for ( var uvi = 0, l = uvs.length; uvi < l; uvi ++ ) {
this.addUVLine( this.parseUVIndex( uvs[ uvi ], uvLen ) );
}
}
};
state.startObject( '', false );
return state;
}
//
function OBJLoader( manager ) {
this.manager = ( manager !== undefined ) ? manager : THREE.DefaultLoadingManager;
this.materials = null;
}
OBJLoader.prototype = {
constructor: OBJLoader,
load: function ( url, onLoad, onProgress, onError ) {
var scope = this;
var loader = new THREE.FileLoader( scope.manager );
loader.setPath( this.path );
loader.load( url, function ( text ) {
onLoad( scope.parse( text ) );
}, onProgress, onError );
},
setPath: function ( value ) {
this.path = value;
return this;
},
setMaterials: function ( materials ) {
this.materials = materials;
return this;
},
parse: function ( text ) {
console.time( 'OBJLoader' );
var state = new ParserState();
if ( text.indexOf( '\r\n' ) !== - 1 ) {
// This is faster than String.split with regex that splits on both
text = text.replace( /\r\n/g, '\n' );
}
if ( text.indexOf( '\\\n' ) !== - 1 ) {
// join lines separated by a line continuation character (\)
text = text.replace( /\\\n/g, '' );
}
var lines = text.split( '\n' );
var line = '', lineFirstChar = '';
var lineLength = 0;
var result = [];
// Faster to just trim left side of the line. Use if available.
var trimLeft = ( typeof ''.trimLeft === 'function' );
for ( var i = 0, l = lines.length; i < l; i ++ ) {
line = lines[ i ];
line = trimLeft ? line.trimLeft() : line.trim();
lineLength = line.length;
if ( lineLength === 0 ) continue;
lineFirstChar = line.charAt( 0 );
// @todo invoke passed in handler if any
if ( lineFirstChar === '#' ) continue;
if ( lineFirstChar === 'v' ) {
var data = line.split( /\s+/ );
switch ( data[ 0 ] ) {
case 'v':
state.vertices.push(
parseFloat( data[ 1 ] ),
parseFloat( data[ 2 ] ),
parseFloat( data[ 3 ] )
);
if ( data.length >= 7 ) {
state.colors.push(
parseFloat( data[ 4 ] ),
parseFloat( data[ 5 ] ),
parseFloat( data[ 6 ] )
);
}
break;
case 'vn':
state.normals.push(
parseFloat( data[ 1 ] ),
parseFloat( data[ 2 ] ),
parseFloat( data[ 3 ] )
);
break;
case 'vt':
state.uvs.push(
parseFloat( data[ 1 ] ),
parseFloat( data[ 2 ] )
);
break;
}
} else if ( lineFirstChar === 'f' ) {
var lineData = line.substr( 1 ).trim();
var vertexData = lineData.split( /\s+/ );
var faceVertices = [];
// Parse the face vertex data into an easy to work with format
for ( var j = 0, jl = vertexData.length; j < jl; j ++ ) {
var vertex = vertexData[ j ];
if ( vertex.length > 0 ) {
var vertexParts = vertex.split( '/' );
faceVertices.push( vertexParts );
}
}
// Draw an edge between the first vertex and all subsequent vertices to form an n-gon
var v1 = faceVertices[ 0 ];
for ( var j = 1, jl = faceVertices.length - 1; j < jl; j ++ ) {
var v2 = faceVertices[ j ];
var v3 = faceVertices[ j + 1 ];
state.addFace(
v1[ 0 ], v2[ 0 ], v3[ 0 ],
v1[ 1 ], v2[ 1 ], v3[ 1 ],
v1[ 2 ], v2[ 2 ], v3[ 2 ]
);
}
} else if ( lineFirstChar === 'l' ) {
var lineParts = line.substring( 1 ).trim().split( " " );
var lineVertices = [], lineUVs = [];
if ( line.indexOf( "/" ) === - 1 ) {
lineVertices = lineParts;
} else {
for ( var li = 0, llen = lineParts.length; li < llen; li ++ ) {
var parts = lineParts[ li ].split( "/" );
if ( parts[ 0 ] !== "" ) lineVertices.push( parts[ 0 ] );
if ( parts[ 1 ] !== "" ) lineUVs.push( parts[ 1 ] );
}
}
state.addLineGeometry( lineVertices, lineUVs );
} else if ( lineFirstChar === 'p' ) {
var lineData = line.substr( 1 ).trim();
var pointData = lineData.split( " " );
state.addPointGeometry( pointData );
} else if ( ( result = object_pattern.exec( line ) ) !== null ) {
// o object_name
// or
// g group_name
// WORKAROUND: https://bugs.chromium.org/p/v8/issues/detail?id=2869
// var name = result[ 0 ].substr( 1 ).trim();
var name = ( " " + result[ 0 ].substr( 1 ).trim() ).substr( 1 );
state.startObject( name );
} else if ( material_use_pattern.test( line ) ) {
// material
state.object.startMaterial( line.substring( 7 ).trim(), state.materialLibraries );
} else if ( material_library_pattern.test( line ) ) {
// mtl file
state.materialLibraries.push( line.substring( 7 ).trim() );
} else if ( lineFirstChar === 's' ) {
result = line.split( ' ' );
// smooth shading
// @todo Handle files that have varying smooth values for a set of faces inside one geometry,
// but does not define a usemtl for each face set.
// This should be detected and a dummy material created (later MultiMaterial and geometry groups).
// This requires some care to not create extra material on each smooth value for "normal" obj files.
// where explicit usemtl defines geometry groups.
// Example asset: examples/models/obj/cerberus/Cerberus.obj
/*
* http://paulbourke.net/dataformats/obj/
* or
* http://www.cs.utah.edu/~boulos/cs3505/obj_spec.pdf
*
* From chapter "Grouping" Syntax explanation "s group_number":
* "group_number is the smoothing group number. To turn off smoothing groups, use a value of 0 or off.
* Polygonal elements use group numbers to put elements in different smoothing groups. For free-form
* surfaces, smoothing groups are either turned on or off; there is no difference between values greater
* than 0."
*/
if ( result.length > 1 ) {
var value = result[ 1 ].trim().toLowerCase();
state.object.smooth = ( value !== '0' && value !== 'off' );
} else {
// ZBrush can produce "s" lines #11707
state.object.smooth = true;
}
var material = state.object.currentMaterial();
if ( material ) material.smooth = state.object.smooth;
} else {
// Handle null terminated files without exception
if ( line === '\0' ) continue;
throw new Error( 'THREE.OBJLoader: Unexpected line: "' + line + '"' );
}
}
state.finalize();
var container = new THREE.Group();
container.materialLibraries = [].concat( state.materialLibraries );
for ( var i = 0, l = state.objects.length; i < l; i ++ ) {
var object = state.objects[ i ];
var geometry = object.geometry;
var materials = object.materials;
var isLine = ( geometry.type === 'Line' );
var isPoints = ( geometry.type === 'Points' );
var hasVertexColors = false;
// Skip o/g line declarations that did not follow with any faces
if ( geometry.vertices.length === 0 ) continue;
var buffergeometry = new THREE.BufferGeometry();
buffergeometry.addAttribute( 'position', new THREE.Float32BufferAttribute( geometry.vertices, 3 ) );
if ( geometry.normals.length > 0 ) {
buffergeometry.addAttribute( 'normal', new THREE.Float32BufferAttribute( geometry.normals, 3 ) );
} else {
buffergeometry.computeVertexNormals();
}
if ( geometry.colors.length > 0 ) {
hasVertexColors = true;
buffergeometry.addAttribute( 'color', new THREE.Float32BufferAttribute( geometry.colors, 3 ) );
}
if ( geometry.uvs.length > 0 ) {
buffergeometry.addAttribute( 'uv', new THREE.Float32BufferAttribute( geometry.uvs, 2 ) );
}
// Create materials
var createdMaterials = [];
for ( var mi = 0, miLen = materials.length; mi < miLen; mi ++ ) {
var sourceMaterial = materials[ mi ];
var material = undefined;
if ( this.materials !== null ) {
material = this.materials.create( sourceMaterial.name );
// mtl etc. loaders probably can't create line materials correctly, copy properties to a line material.
if ( isLine && material && ! ( material instanceof THREE.LineBasicMaterial ) ) {
var materialLine = new THREE.LineBasicMaterial();
THREE.Material.prototype.copy.call( materialLine, material );
materialLine.color.copy( material.color );
materialLine.lights = false;
material = materialLine;
} else if ( isPoints && material && ! ( material instanceof THREE.PointsMaterial ) ) {
var materialPoints = new THREE.PointsMaterial( { size: 10, sizeAttenuation: false } );
THREE.Material.prototype.copy.call( materialPoints, material );
materialPoints.color.copy( material.color );
materialPoints.map = material.map;
materialPoints.lights = false;
material = materialPoints;
}
}
if ( ! material ) {
if ( isLine ) {
material = new THREE.LineBasicMaterial();
} else if ( isPoints ) {
material = new THREE.PointsMaterial( { size: 1, sizeAttenuation: false } );
} else {
material = new THREE.MeshPhongMaterial();
}
material.name = sourceMaterial.name;
}
material.flatShading = sourceMaterial.smooth ? false : true;
material.vertexColors = hasVertexColors ? THREE.VertexColors : THREE.NoColors;
createdMaterials.push( material );
}
// Create mesh
var mesh;
if ( createdMaterials.length > 1 ) {
for ( var mi = 0, miLen = materials.length; mi < miLen; mi ++ ) {
var sourceMaterial = materials[ mi ];
buffergeometry.addGroup( sourceMaterial.groupStart, sourceMaterial.groupCount, mi );
}
if ( isLine ) {
mesh = new THREE.LineSegments( buffergeometry, createdMaterials );
} else if ( isPoints ) {
mesh = new THREE.Points( buffergeometry, createdMaterials );
} else {
mesh = new THREE.Mesh( buffergeometry, createdMaterials );
}
} else {
if ( isLine ) {
mesh = new THREE.LineSegments( buffergeometry, createdMaterials[ 0 ] );
} else if ( isPoints ) {
mesh = new THREE.Points( buffergeometry, createdMaterials[ 0 ] );
} else {
mesh = new THREE.Mesh( buffergeometry, createdMaterials[ 0 ] );
}
}
mesh.name = object.name;
container.add( mesh );
}
console.timeEnd( 'OBJLoader' );
return container;
}
};
return OBJLoader;
} )();
+877
View File
@@ -0,0 +1,877 @@
/**
* bambuddy_adapter.js
* Bridges OctoPrint-PrettyGCode to Bambuddy's API.
*
* Load this BEFORE prettygcode.js. It provides:
* - OCTOPRINT_VIEWMODELS shim
* - Minimal KnockoutJS observable shim (ko.observable)
* - fetch() + XHR interceptors for path rewriting
* - Bambuddy WebSocket → fromCurrentData bridge
* - File picker backed by Bambuddy's library API
* - Settings load/save via plugin settings endpoint
*
* What works:
* - Full 3D GCode visualisation
* - Dark mode and all dat.GUI settings
* - File selection from Bambuddy's file library
* - Print progress highlight (% based)
* - Auto-load currently printing file
*
* What doesn't work (Bambu hardware limitation):
* - Live nozzle animation during printing — Bambu printers do not expose
* GCode serial echo logs (Send: G1 X...), so PrintHeadSimulator has no input.
*/
(function () {
'use strict';
const API_BASE = '/api/v1';
const VIEWER_BASE = '/gcode-viewer'; // static assets now served from here
// -------------------------------------------------------------------------
// Auth helper
// -------------------------------------------------------------------------
function authHeaders() {
// sessionStorage is used when the user opts out of "remember me";
// fall back to localStorage for persistent sessions.
const token = sessionStorage.getItem('auth_token') ?? localStorage.getItem('auth_token');
return token ? { Authorization: 'Bearer ' + token } : {};
}
// When auth is enabled and the user has no valid token, every API call
// returns 401 and the viewer chrome stays on screen showing empty state.
// Intercept the first 401 and hand control back to the SPA, which owns
// the login flow and will redirect to /login when appropriate.
let _authRedirectFired = false;
function apiFetch(path, opts) {
return fetch(API_BASE + path, {
...opts,
headers: { ...authHeaders(), ...(opts && opts.headers) },
cache: 'no-store',
}).then((response) => {
if (response.status === 401 && !_authRedirectFired) {
_authRedirectFired = true;
try {
sessionStorage.removeItem('auth_token');
localStorage.removeItem('auth_token');
} catch (e) { /* storage unavailable */ }
window.top.location.replace('/');
}
return response;
});
}
// -------------------------------------------------------------------------
// 1. Minimal KnockoutJS shim (ko.observable / ko.computed)
// -------------------------------------------------------------------------
window.ko = {
observable: function (initial) {
var _val = initial;
var _subs = [];
var obs = function (newVal) {
if (arguments.length > 0) {
_val = newVal;
_subs.forEach(function (cb) { try { cb(newVal); } catch (e) {} });
}
return _val;
};
obs.subscribe = function (cb) {
_subs.push(cb);
return { dispose: function () { _subs = _subs.filter(function (s) { return s !== cb; }); } };
};
obs.peek = function () { return _val; };
return obs;
},
computed: function (fn) {
var obs = window.ko.observable(null);
try { obs(fn()); } catch (e) {}
return obs;
},
pureComputed: function (fn) { return window.ko.computed(fn); },
mapping: { fromJS: function (obj) { return obj; } },
};
// -------------------------------------------------------------------------
// 2. OCTOPRINT_VIEWMODELS registration shim
// -------------------------------------------------------------------------
window.OCTOPRINT_VIEWMODELS = [];
// -------------------------------------------------------------------------
// 3. Fake OctoPrint settings / printer profile / login viewmodels
// -------------------------------------------------------------------------
var fakeSettings = {
webcam: {
streamUrl: ko.observable(''),
flipH: ko.observable(false),
flipV: ko.observable(false),
rotate90: ko.observable(false),
},
plugins: {
prettygcode: {
darkMode: ko.observable(false),
},
},
};
// Bed sizes for common Bambu models (mm)
var BAMBU_BED_SIZES = {
'X1': { width: 256, depth: 256, height: 256 },
'X1C': { width: 256, depth: 256, height: 256 },
'X1E': { width: 256, depth: 256, height: 256 },
'P1S': { width: 256, depth: 256, height: 256 },
'P1P': { width: 256, depth: 256, height: 256 },
'A1': { width: 300, depth: 300, height: 300 },
'A1 Mini': { width: 180, depth: 180, height: 180 },
};
var DEFAULT_BED = { width: 256, depth: 256, height: 256 };
var currentBed = Object.assign({}, DEFAULT_BED);
function makeFakeProfileData(bed) {
return {
volume: {
width: ko.observable(bed.width),
depth: ko.observable(bed.depth),
height: ko.observable(bed.height),
origin: ko.observable('lowerleft'),
formFactor: ko.observable('rectangular'),
// Make custom_box a function so prettygcode.js uses width()/depth()/height()
custom_box: function () { return false; },
},
};
}
var fakePrinterProfiles = {
currentProfileData: ko.observable(makeFakeProfileData(currentBed)),
};
var fakeLoginState = {
isUser: ko.observable(true),
isAdmin: ko.observable(false),
};
var fakeControl = {};
// -------------------------------------------------------------------------
// 4. fetch() interceptor — rewrite OctoPrint paths to Bambuddy
// -------------------------------------------------------------------------
var _originalFetch = window.fetch.bind(window);
window.fetch = function (resource, init) {
var url = (typeof resource === 'string') ? resource
: (resource && resource.url) ? resource.url
: null;
if (url) {
// Normalize: strip scheme+host so regexes work on the path regardless
// of whether the browser resolved a relative URL to absolute.
var path = url.replace(/^https?:\/\/[^\/]+/, '');
// Also strip the viewer's own path prefix — the browser resolves relative URLs
// like 'downloads/files/local/...' to '/gcode-viewer/downloads/...' because
// the page is served from /gcode-viewer/. The regexes below expect bare paths.
path = path.replace(/^\/gcode-viewer(?=\/|$)/, '');
var newPath = path;
// OctoPrint file download → Bambuddy library download
newPath = newPath.replace(
/^\/?downloads\/files\/local\/__bambuddy_file_(\d+)$/,
API_BASE + '/library/files/$1/download'
);
// OctoPrint plugin static assets → gcode-viewer static files
newPath = newPath.replace(
/^\/?plugin\/prettygcode\/static\//,
VIEWER_BASE + '/'
);
if (newPath !== path) {
url = newPath;
resource = url; // always pass as string after rewriting
}
// Inject auth header for all Bambuddy API calls
if (url.startsWith(API_BASE)) {
var hdrs = authHeaders();
init = init || {};
init.headers = Object.assign({}, hdrs, init.headers || {});
}
}
var promise = _originalFetch(resource, init);
// Tee GCode downloads to build the layer map for sync + nozzle animation
if (url && url.match(/\/library\/files\/\d+\/download/)) {
promise = promise.then(function (response) {
var clone = response.clone();
clone.text().then(function (text) {
gcodeLayerMap = parseGcodeLayerMap(text);
lastFedLayer = -1;
console.log('[PrettyGCode] Parsed ' + gcodeLayerMap.layerOffsets.length +
' layers for sync (' + Math.round(gcodeLayerMap.totalBytes / 1024) + ' KB)');
}).catch(function (e) {
console.warn('[PrettyGCode] GCode layer parse failed:', e);
});
return response;
});
}
return promise;
};
// -------------------------------------------------------------------------
// 5. XHR interceptor — rewrite OctoPrint paths (used by THREE.OBJLoader etc.)
// -------------------------------------------------------------------------
var _origXHROpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (method, url) {
if (typeof url === 'string') {
// Strip host if absolute, then rewrite OctoPrint static asset paths
var path = url.replace(/^https?:\/\/[^\/]+/, '');
path = path.replace(/^\/?plugin\/prettygcode\/static\//, VIEWER_BASE + '/');
url = path;
}
var args = Array.prototype.slice.call(arguments);
args[1] = url;
return _origXHROpen.apply(this, args);
};
// -------------------------------------------------------------------------
// 6. GCode layer parser
//
// Builds a layer map from the raw GCode text so we can:
// a) Map layer_num → byte offset in file (drives prettygcode's filepos sync
// and layer highlight, same as if OctoPrint were reporting filepos)
// b) Extract a set of G0/G1 commands per layer to feed the PrintHeadSimulator
// as synthetic "Send: G1 X... Y... Z..." entries, animating the nozzle model.
//
// Layer detection mirrors prettygcode.js: a new layer starts on the first extrusion
// at a Z position we haven't extruded at before.
// -------------------------------------------------------------------------
function parseGcodeLayerMap(text) {
var lines = text.split('\n');
var layerOffsets = []; // layerOffsets[i] = byte pos in file where layer i starts
var layerCmds = []; // layerCmds[i] = array of ' G1 X... Y... Z...' strings
var byteOffset = 0;
var x = 0, y = 0, z = 0, e = 0;
var relative = false, relativeE = false;
var currentLayerZ = null;
var curCmds = [];
for (var i = 0; i < lines.length; i++) {
var raw = lines[i];
// +1 for the \n that was consumed by split
var lineBytes = raw.length + 1;
var cmd = raw.replace(/;.*$/, '').trim();
if (!cmd) { byteOffset += lineBytes; continue; }
var parts = cmd.split(/\s+/);
var g = parts[0].toUpperCase();
if (g === 'G90') { relative = false; relativeE = false; }
else if (g === 'G91') { relative = true; relativeE = true; }
else if (g === 'M82') { relativeE = false; }
else if (g === 'M83') { relativeE = true; }
else if (g === 'G92') {
// coordinate reset
for (var p = 1; p < parts.length; p++) {
var k0 = parts[p][0].toUpperCase();
var v0 = parseFloat(parts[p].slice(1));
if (!isNaN(v0)) {
if (k0 === 'X') x = v0;
else if (k0 === 'Y') y = v0;
else if (k0 === 'Z') z = v0;
else if (k0 === 'E') e = v0;
}
}
} else if (g === 'G0' || g === 'G1') {
var nx = x, ny = y, nz = z, ne = e;
var hasE = false;
for (var p = 1; p < parts.length; p++) {
if (!parts[p]) continue;
var k1 = parts[p][0].toUpperCase();
var v1 = parseFloat(parts[p].slice(1));
if (isNaN(v1)) continue;
if (k1 === 'X') nx = relative ? x + v1 : v1;
else if (k1 === 'Y') ny = relative ? y + v1 : v1;
else if (k1 === 'Z') nz = relative ? z + v1 : v1;
else if (k1 === 'E') { ne = relativeE ? e + v1 : v1; hasE = true; }
}
// New layer: first extrusion at a new Z (same logic as prettygcode.js)
if (hasE && ne > e && nz !== currentLayerZ) {
currentLayerZ = nz;
if (curCmds.length > 0) layerCmds.push(curCmds);
else if (layerOffsets.length > 0) layerCmds.push([]); // gap layer
curCmds = [];
layerOffsets.push(byteOffset);
}
// Record movement commands for nozzle sim (keep arrays small — max 500/layer)
if ((hasE || nz !== z) && curCmds.length < 500) {
curCmds.push(' G1 X' + nx.toFixed(3) +
' Y' + ny.toFixed(3) +
' Z' + nz.toFixed(3));
}
x = nx; y = ny; z = nz; e = ne;
}
byteOffset += lineBytes;
}
if (curCmds.length > 0) layerCmds.push(curCmds);
return {
layerOffsets: layerOffsets,
layerCmds: layerCmds,
totalBytes: byteOffset,
};
}
// -------------------------------------------------------------------------
// 8. State
// -------------------------------------------------------------------------
var viewModel = null;
var currentFileId = null;
var currentFilename = null;
var currentFileDate = 0; // stable epoch — only changes when a new file is loaded
var ws = null;
var wsReconnectTimer = null;
var printers = []; // [{id, name, model, state, progress, subtask_name}]
var selectedPrinterId = null;
var gcodeLayerMap = null; // parsed layer data: {layerOffsets, layerCmds, totalBytes}
var lastFedLayer = -1; // last layer_num whose commands we fed to printHeadSim
// -------------------------------------------------------------------------
// 9. Bambuddy WebSocket
// -------------------------------------------------------------------------
function connectWebSocket() {
var token = localStorage.getItem('auth_token');
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
// Do NOT put the token in the URL — it would appear in server logs.
// The WebSocket endpoint is currently unauthenticated server-side;
// all sensitive calls go through authenticated fetch() instead.
var wsUrl = proto + '//' + location.host + API_BASE + '/ws';
ws = new WebSocket(wsUrl);
ws.onopen = function () {
console.log('[PrettyGCode] Connected to Bambuddy WebSocket');
};
ws.onmessage = function (event) {
try {
var msg = JSON.parse(event.data);
if (msg.type === 'printer_status') {
handlePrinterStatus(msg.printer_id, msg.data);
}
} catch (e) {}
};
ws.onclose = function () {
clearTimeout(wsReconnectTimer);
wsReconnectTimer = setTimeout(connectWebSocket, 3000);
};
ws.onerror = function () {
ws.close();
};
}
function bambuStateToOctoState(bambuState) {
var map = {
RUNNING: 'Printing',
PAUSE: 'Paused',
FAILED: 'Error',
FINISH: 'Operational',
IDLE: 'Operational',
};
return map[bambuState] || 'Operational';
}
function handlePrinterStatus(printerId, data) {
// Update printer list entry
var found = false;
for (var i = 0; i < printers.length; i++) {
if (printers[i].id === printerId) {
// Allowlist to prevent prototype pollution from crafted WS messages
var allowed2 = ['name', 'state', 'progress', 'layer_num', 'subtask_name', 'gcode_file', 'camera_url', 'model'];
allowed2.forEach(function (k) { if (k in data) printers[i][k] = data[k]; });
found = true;
break;
}
}
if (!found) {
// Only copy known, safe keys — avoids prototype pollution from a crafted WS message
var allowed = ['name', 'state', 'progress', 'layer_num', 'subtask_name', 'gcode_file', 'camera_url', 'model'];
var entry = { id: printerId };
allowed.forEach(function (k) { if (k in data) entry[k] = data[k]; });
printers.push(entry);
}
updatePrinterSelector();
// Only feed data for the selected printer
if (selectedPrinterId !== null && printerId !== selectedPrinterId) return;
if (selectedPrinterId === null && printers.length > 0) {
selectedPrinterId = printers[0].id;
}
if (!viewModel) return;
var printer = null;
for (var j = 0; j < printers.length; j++) {
if (printers[j].id === printerId) { printer = printers[j]; break; }
}
if (!printer) return;
// Update bed size from printer model
var bedKey = (printer.model || '').toUpperCase();
for (var modelName in BAMBU_BED_SIZES) {
if (bedKey.indexOf(modelName.toUpperCase()) !== -1) {
currentBed = BAMBU_BED_SIZES[modelName];
break;
}
}
// Replace the entire profile data so the subscribe() fires
fakePrinterProfiles.currentProfileData(makeFakeProfileData(currentBed));
// Auto-load currently printing file if it changed
var subtask = printer.subtask_name || printer.gcode_file || '';
if (subtask && subtask !== currentFilename) {
currentFilename = subtask;
tryAutoLoadPrintingFile(subtask);
}
// Update webcam URL
if (printer.camera_url) {
fakeSettings.webcam.streamUrl(printer.camera_url);
}
feedCurrentData(printer);
}
function feedCurrentData(printer) {
if (!viewModel || !viewModel.fromCurrentData) return;
var octoState = bambuStateToOctoState(printer.state || 'IDLE');
var isPrinting = octoState === 'Printing' || octoState === 'Paused';
// --- Layer sync via filepos -------------------------------------------
// prettygcode.js calls gcodeProxy.syncGcodeObjToFilePos(curPrintFilePos) each
// animation frame when printing + syncToProgress is on. Pass the byte offset
// of the current layer so the highlight advances correctly.
var filepos = null;
var logs = [];
if (gcodeLayerMap && isPrinting) {
// Bambu layer_num is 1-based; our layerOffsets array is 0-based.
var layerIdx = Math.max(0, (printer.layer_num || 1) - 1);
layerIdx = Math.min(layerIdx, gcodeLayerMap.layerOffsets.length - 1);
filepos = gcodeLayerMap.layerOffsets[layerIdx] || 0;
// --- Nozzle animation via synthetic Send: commands -------------------
// PrintHeadSimulator.addCommand() expects "Send: G1 X... Y... Z..." entries.
// Feed the movement commands for the current layer once per layer change.
// The simulator interpolates them over real time, animating the nozzle model.
if (layerIdx !== lastFedLayer && gcodeLayerMap.layerCmds[layerIdx]) {
lastFedLayer = layerIdx;
var cmds = gcodeLayerMap.layerCmds[layerIdx];
// PrintHeadSimulator buffer is capped at 1000; feed at most 400 commands
// so there's room for the sim to drain before more arrive.
logs = cmds.slice(0, 400).map(function (c) { return 'Send:' + c; });
}
}
viewModel.fromCurrentData({
job: {
file: {
path: currentFileId ? ('__bambuddy_file_' + currentFileId) : null,
date: currentFileDate,
},
estimatedPrintTime: null,
},
state: {
text: octoState,
flags: { printing: octoState === 'Printing', paused: octoState === 'Paused' },
},
progress: {
filepos: filepos,
completion: (printer.progress || 0) / 100,
printTime: null,
},
currentZ: null,
logs: logs,
});
}
// -------------------------------------------------------------------------
// 8. Auto-load file when printer starts printing
// -------------------------------------------------------------------------
function tryAutoLoadPrintingFile(filename) {
// Search the library for a matching .gcode file
apiFetch('/library/files?sort_by=updated_at&sort_dir=desc', {})
.then(function (r) { return r.json(); })
.then(function (files) {
if (!Array.isArray(files)) return;
var match = files.find(function (f) {
return f.filename === filename ||
f.filename === filename + '.gcode' ||
f.filename.replace(/\.gcode$/, '') === filename.replace(/\.gcode$/, '');
});
if (match) loadFileById(match.id, match.filename, match.file_size);
})
.catch(function () {});
}
// -------------------------------------------------------------------------
// 9. File loading
// -------------------------------------------------------------------------
function loadFileById(fileId, filename, fileSize) {
currentFileId = fileId;
currentFilename = filename;
currentFileDate = Date.now(); // new stable date so prettygcode loads exactly once
gcodeLayerMap = null; // cleared here; re-populated when fetch() intercept fires
lastFedLayer = -1;
stopPlayback(true);
updateFilenameDisplay(filename);
// Enable play button once a file is loaded
var playBtn = document.getElementById('bb-play-btn');
if (playBtn) playBtn.disabled = false;
// Trigger prettygcode.js's updateJob — date must match currentFileDate exactly
// so subsequent feedCurrentData calls don't re-trigger the download
if (viewModel && viewModel.fromCurrentData) {
viewModel.fromCurrentData({
job: {
file: {
path: '__bambuddy_file_' + fileId,
date: currentFileDate,
},
estimatedPrintTime: null,
},
state: { text: 'Operational', flags: { printing: false } },
progress: { filepos: null, completion: 0 },
currentZ: null,
logs: [],
});
}
}
function updateFilenameDisplay(filename) {
var el = document.getElementById('bb-current-file');
if (el) el.textContent = filename || '— no file loaded —';
}
// -------------------------------------------------------------------------
// 10. File picker
// -------------------------------------------------------------------------
function buildFilePicker() {
var container = document.getElementById('bb-file-picker');
if (!container) return;
var input = document.createElement('input');
input.type = 'text';
input.placeholder = 'Search .gcode files…';
input.className = 'bb-search';
input.style.cssText = 'width:100%;padding:4px 8px;background:#333;border:1px solid #555;color:#fff;border-radius:4px;margin-bottom:4px;box-sizing:border-box;';
var list = document.createElement('div');
list.style.cssText = 'max-height:180px;overflow-y:auto;';
container.appendChild(input);
container.appendChild(list);
var allFiles = [];
function render(files) {
list.innerHTML = '';
if (!files.length) {
list.innerHTML = '<div style="color:#888;padding:4px 6px;font-size:12px;">No .gcode files found in library</div>';
return;
}
files.forEach(function (f) {
var row = document.createElement('div');
row.textContent = f.filename;
row.title = f.filename;
row.style.cssText = 'padding:4px 6px;cursor:pointer;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;border-radius:3px;';
row.addEventListener('mouseenter', function () { row.style.background = '#444'; });
row.addEventListener('mouseleave', function () { row.style.background = ''; });
row.addEventListener('click', function () {
loadFileById(f.id, f.filename, f.file_size);
// Close picker
container.classList.toggle('bb-open', false);
});
list.appendChild(row);
});
}
function loadFiles() {
list.innerHTML = '<div style="color:#aaa;padding:4px 6px;font-size:12px;">Loading files…</div>';
// include_root=false returns files from ALL folders, not just root level
apiFetch('/library/files?include_root=false', {})
.then(function (r) { return r.json(); })
.then(function (files) {
if (!Array.isArray(files)) {
list.innerHTML = '<div style="color:#f88;padding:4px 6px;font-size:12px;">Failed to load files</div>';
return;
}
allFiles = files.filter(function (f) {
return f.filename && f.filename.toLowerCase().endsWith('.gcode');
});
render(allFiles);
})
.catch(function () {
list.innerHTML = '<div style="color:#f88;padding:4px 6px;font-size:12px;">Failed to load files — check auth token</div>';
});
}
input.addEventListener('input', function () {
var q = input.value.toLowerCase();
render(q ? allFiles.filter(function (f) { return f.filename.toLowerCase().indexOf(q) !== -1; }) : allFiles);
});
loadFiles();
}
// -------------------------------------------------------------------------
// 11. Printer selector
// -------------------------------------------------------------------------
function updatePrinterSelector() {
var sel = document.getElementById('bb-printer-select');
if (!sel) return;
var current = sel.value;
sel.innerHTML = '';
printers.forEach(function (p) {
var opt = document.createElement('option');
opt.value = p.id;
opt.textContent = (p.name || ('Printer ' + p.id)) + (p.state ? ' [' + p.state + ']' : '');
sel.appendChild(opt);
});
if (current) sel.value = current;
if (!sel.value && printers.length) {
sel.value = printers[0].id;
selectedPrinterId = printers[0].id;
}
}
// -------------------------------------------------------------------------
// 13. Initialise after DOM + scripts are ready
// -------------------------------------------------------------------------
function init() {
// Find the ViewModel registration that prettygcode.js pushed
var reg = null;
for (var i = 0; i < window.OCTOPRINT_VIEWMODELS.length; i++) {
if (window.OCTOPRINT_VIEWMODELS[i].construct) {
reg = window.OCTOPRINT_VIEWMODELS[i];
break;
}
}
if (!reg) {
console.error('[PrettyGCode] No ViewModel found in OCTOPRINT_VIEWMODELS');
return;
}
try {
viewModel = new reg.construct([
fakeSettings,
fakeLoginState,
fakePrinterProfiles,
fakeControl,
]);
} catch (e) {
console.error('[PrettyGCode] ViewModel constructor failed:', e);
return;
}
if (viewModel.onAfterBinding) {
try { viewModel.onAfterBinding(); } catch (e) {}
}
// Trigger tab activation — this calls onTabChange which initialises the Three.js scene
if (viewModel.onTabChange) {
try { viewModel.onTabChange('#tab_plugin_prettygcode', ''); } catch (e) {
console.error('[PrettyGCode] onTabChange failed:', e);
}
}
connectWebSocket();
// Wire up printer selector
var sel = document.getElementById('bb-printer-select');
if (sel) {
sel.addEventListener('change', function () {
selectedPrinterId = parseInt(sel.value, 10) || null;
});
}
// Load initial printer list
apiFetch('/printers', {})
.then(function (r) { return r.json(); })
.then(function (list) {
if (!Array.isArray(list)) return;
list.forEach(function (p) {
// Find existing entry (WS may have pushed one before API returned)
var existing = null;
for (var i = 0; i < printers.length; i++) {
if (printers[i].id === p.id) { existing = printers[i]; break; }
}
if (existing) {
// Fill in name/model that WS status messages don't carry
if (p.name) existing.name = p.name;
if (p.model) existing.model = p.model;
} else {
printers.push({ id: p.id, name: p.name, model: p.model, state: 'IDLE', progress: 0 });
}
});
updatePrinterSelector();
// Try to get bed size from first printer model
if (list.length > 0 && list[0].model) {
var m = list[0].model.toUpperCase();
for (var modelName in BAMBU_BED_SIZES) {
if (m.indexOf(modelName.toUpperCase()) !== -1) {
currentBed = BAMBU_BED_SIZES[modelName];
fakePrinterProfiles.currentProfileData(makeFakeProfileData(currentBed));
break;
}
}
}
if (list.length > 0) selectedPrinterId = list[0].id;
})
.catch(function () {});
console.log('[PrettyGCode] Bambuddy adapter initialised');
// Wire up playback controls
var playBtn = document.getElementById('bb-play-btn');
var speedSel = document.getElementById('bb-play-speed');
if (playBtn) {
playBtn.addEventListener('click', function () {
if (isPlaying) stopPlayback();
else startPlayback();
});
}
if (speedSel) {
speedSel.addEventListener('change', function () {
layersPerTick = parseInt(speedSel.value, 10) || 1;
// Restart if already playing so speed takes effect immediately
if (isPlaying) { stopPlayback(); startPlayback(); }
});
}
}
// -------------------------------------------------------------------------
// 14. Playback engine
// -------------------------------------------------------------------------
var isPlaying = false;
var playInterval = null;
var layersPerTick = 1; // layers advanced per 50 ms tick
var TICK_MS = 50; // ~20 fps
function getSlider() { return $('#myslider-vertical'); }
function startPlayback() {
var $sl = getSlider();
if (!$sl.length) return;
var data = $sl.data('_pgslider');
if (!data) return;
var max = data.opts.max || 0;
if (max === 0) return;
// Restart from beginning if already at the end
var cur = data.opts.value || 0;
if (cur >= max) cur = 0;
// Suppress live-print sync while playing
var evStart = $.Event('slideStart'); evStart.value = cur; $sl.trigger(evStart);
_setSliderLayer($sl, cur);
isPlaying = true;
_updatePlayBtn();
playInterval = setInterval(function () {
var d = getSlider().data('_pgslider');
if (!d) { stopPlayback(); return; }
var next = (d.opts.value || 0) + layersPerTick;
if (next >= d.opts.max) {
next = d.opts.max;
_setSliderLayer(getSlider(), next);
stopPlayback(/* skipEvStop */ false);
return;
}
_setSliderLayer(getSlider(), next);
}, TICK_MS);
}
function stopPlayback(skipEvStop) {
if (playInterval) { clearInterval(playInterval); playInterval = null; }
isPlaying = false;
_updatePlayBtn();
if (!skipEvStop) {
var $sl = getSlider();
if ($sl.length) {
var d = $sl.data('_pgslider');
var evStop = $.Event('slideStop');
evStop.value = d ? d.opts.value : 0;
$sl.trigger(evStop);
}
}
}
function _setSliderLayer($sl, layer) {
$sl.slider('setValue', layer);
var ev = $.Event('slide'); ev.value = layer; $sl.trigger(ev);
$sl.find('.slider-handle').text(layer);
}
function _updatePlayBtn() {
var btn = document.getElementById('bb-play-btn');
if (btn) btn.textContent = isPlaying ? '⏸' : '▶';
}
// Run after all scripts have loaded.
// buildFilePicker() runs immediately at DOM-ready — independent of viewmodel
// init so the file picker is always functional even if prettygcode fails.
// init() (viewmodel + 3D canvas) runs 200 ms later to let prettygcode.js
// finish its own synchronous setup first.
function onDomReady() {
// Wire file-picker button — MUST be here (not an inline <script>) because
// the CSP on this page allows script-src 'self' but NOT 'unsafe-inline',
// so inline <script> blocks are blocked by the browser.
var fileBtn = document.getElementById('bb-file-btn');
var picker = document.getElementById('bb-file-picker');
if (fileBtn && picker) {
fileBtn.addEventListener('click', function (e) {
picker.classList.toggle('bb-open');
e.stopPropagation();
});
// Clicking outside the picker closes it
document.addEventListener('click', function () {
picker.classList.remove('bb-open');
});
// Clicks inside the picker don't close it
picker.addEventListener('click', function (e) {
e.stopPropagation();
});
}
buildFilePicker();
setTimeout(init, 200);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', onDomReady);
} else {
onDomReady();
}
// -------------------------------------------------------------------------
// Public API
// -------------------------------------------------------------------------
window.BambuddyPrettyGCode = {
loadFile: loadFileById,
getViewModel: function () { return viewModel; },
play: startPlayback,
stop: stopPlayback,
};
})();
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
View File
File diff suppressed because one or more lines are too long
@@ -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
+142
View File
@@ -0,0 +1,142 @@
/**
* slider-shim.js
* Minimal jQuery plugin shim for the bootstrap-slider API used by prettygcode.js.
*
* Supports the subset used by PrettyGCode:
* $(el).slider(opts) — init
* $(el).slider("setValue", v) — set value
* $(el).slider("setMax", v) — update max
* $(el).on("slide", fn) — fires with event.value
* $(el).on("slideStart", fn)
* $(el).on("slideStop", fn)
*/
(function ($) {
'use strict';
$.fn.slider = function (optsOrCmd, cmdArg1, cmdArg2, cmdArg3) {
return this.each(function () {
var $el = $(this);
var data = $el.data('_pgslider');
// ---------- init ----------
if (!data || typeof optsOrCmd === 'object') {
var opts = $.extend({
id: null,
orientation: 'horizontal',
reversed: false,
min: 0,
max: 100,
value: 0,
}, typeof optsOrCmd === 'object' ? optsOrCmd : {});
// Build the DOM
var isVertical = opts.orientation === 'vertical';
var trackHtml =
'<div class="slider' + (isVertical ? ' slider-vertical' : '') + '"' +
(opts.id ? ' id="' + opts.id + '"' : '') + '>' +
'<div class="slider-track"><div class="slider-selection"></div></div>' +
'<div class="slider-handle round">0</div>' +
'</div>';
$el.html(trackHtml);
var $slider = $el.find('.slider');
var $handle = $el.find('.slider-handle');
var $selection = $el.find('.slider-selection');
var isDragging = false;
data = {
opts: opts,
$slider: $slider,
$handle: $handle,
$selection: $selection,
};
$el.data('_pgslider', data);
function pct(v) {
var range = data.opts.max - data.opts.min;
if (range === 0) return 0;
var p = (v - data.opts.min) / range * 100;
return opts.reversed ? 100 - p : p;
}
function updateUI(val) {
var p = pct(val);
$handle.text(val);
if (isVertical) {
$handle.css({ top: p + '%', bottom: '' });
$selection.css({ height: (100 - p) + '%', top: p + '%' });
} else {
$handle.css({ left: p + '%' });
$selection.css({ width: p + '%' });
}
}
data.updateUI = updateUI;
updateUI(opts.value);
data.opts.value = opts.value;
// Mouse interaction
function getValueFromEvent(e) {
var offset = $slider.offset();
var range = data.opts.max - data.opts.min;
var p;
if (isVertical) {
var h = $slider.height();
p = (e.pageY - offset.top) / h;
} else {
var w = $slider.width();
p = (e.pageX - offset.left) / w;
}
p = Math.max(0, Math.min(1, p));
if (opts.reversed) p = 1 - p;
return Math.round(data.opts.min + p * range);
}
$slider.on('mousedown', function (e) {
isDragging = true;
var val = getValueFromEvent(e);
data.opts.value = val;
updateUI(val);
var ev = $.Event('slideStart'); ev.value = val;
$el.trigger(ev);
e.preventDefault();
});
$(document).on('mousemove.pgslider_' + $el.attr('id'), function (e) {
if (!isDragging) return;
var val = getValueFromEvent(e);
data.opts.value = val;
updateUI(val);
var ev = $.Event('slide'); ev.value = val;
$el.trigger(ev);
});
$(document).on('mouseup.pgslider_' + $el.attr('id'), function (e) {
if (!isDragging) return;
isDragging = false;
var val = getValueFromEvent(e);
data.opts.value = val;
updateUI(val);
var ev = $.Event('slideStop'); ev.value = val;
$el.trigger(ev);
});
return;
}
// ---------- commands ----------
if (optsOrCmd === 'setValue') {
data.opts.value = cmdArg1;
data.updateUI(cmdArg1);
// prettygcode.js calls slider('setValue', N, false, true) after loading
// — the third arg means "trigger the slide event so listeners update state"
if (cmdArg3) {
var ev = $.Event('slide'); ev.value = cmdArg1; $el.trigger(ev);
}
} else if (optsOrCmd === 'setMax') {
data.opts.max = cmdArg1;
data.updateUI(data.opts.value);
}
});
};
}(jQuery));
+996
View File
File diff suppressed because one or more lines are too long