Merge branch 'rel-4.6'

This commit is contained in:
Stewart Allen 2026-01-11 15:29:15 -05:00
commit c6c9846b96
184 changed files with 15553 additions and 9433 deletions

10
.gitignore vendored
View file

@ -1,5 +1,7 @@
*.pem
.DS_Store .DS_Store
.bache .bcache
.claude
.docusaurus .docusaurus
.env .env
alt alt
@ -7,12 +9,14 @@ app.json
build build
data data
dist dist
jszip-esm.js
logs logs
mod mod
node_modules node_modules
pack pack
package-lock.json package-lock.json
three.js src/ext/jszip-esm.js
src/ext/quickjs.js
src/ext/three.js
tmp tmp
tmp_*/
web/boot/bundle* web/boot/bundle*

11
app.js
View file

@ -579,16 +579,11 @@ function cookieValue(cookie,key) {
} }
function addAppHeaders(req, res, next) { function addAppHeaders(req, res, next) {
if ([ if (req.app.path.indexOf("/lib") === 0) {
addCorsHeaders(req, res);
} else if ([
"/kiri/", "/kiri/",
"/mesh/", "/mesh/",
"/lib/gpu/raster.js",
"/lib/gpu/raster-worker.js",
"/lib/mesh/work.js",
"/lib/kiri/run/worker.js",
"/lib/kiri/run/minion.js",
"/lib/kiri/run/engine.js",
"/lib/kiri/run/frame.js"
].indexOf(req.app.path) >= 0) { ].indexOf(req.app.path) >= 0) {
addCorsHeaders(req, res); addCorsHeaders(req, res);
} }

View file

@ -0,0 +1,28 @@
import * as QuickJSModule from "../node_modules/quickjs-emscripten/dist/index.js";
// Re-export all the named exports
export const getQuickJS = QuickJSModule.getQuickJS;
export const getQuickJSSync = QuickJSModule.getQuickJSSync;
export const newQuickJSWASMModule = QuickJSModule.newQuickJSWASMModule;
export const newQuickJSAsyncWASMModule = QuickJSModule.newQuickJSAsyncWASMModule;
export const newAsyncRuntime = QuickJSModule.newAsyncRuntime;
export const newAsyncContext = QuickJSModule.newAsyncContext;
export const DEBUG_SYNC = QuickJSModule.DEBUG_SYNC;
export const DEBUG_ASYNC = QuickJSModule.DEBUG_ASYNC;
export const RELEASE_SYNC = QuickJSModule.RELEASE_SYNC;
export const RELEASE_ASYNC = QuickJSModule.RELEASE_ASYNC;
export const errors = QuickJSModule.errors;
export const memoizePromiseFactory = QuickJSModule.memoizePromiseFactory;
export const Lifetime = QuickJSModule.Lifetime;
export const Scope = QuickJSModule.Scope;
export const WeakLifetime = QuickJSModule.WeakLifetime;
export const StaticLifetime = QuickJSModule.StaticLifetime;
export const TestQuickJSWASMModule = QuickJSModule.TestQuickJSWASMModule;
export const isFail = QuickJSModule.isFail;
export const isSuccess = QuickJSModule.isSuccess;
export const assertSync = QuickJSModule.assertSync;
export const DeferredPromise = QuickJSModule.DeferredPromise;
export const shouldInterruptAfterDeadline = QuickJSModule.shouldInterruptAfterDeadline;
// Default export
export default QuickJSModule;

View file

@ -0,0 +1,50 @@
const path = require('path');
const webpack = require('webpack');
module.exports = {
mode: 'production',
entry: path.resolve(__dirname, './webpack-quickjs-bundle.js'),
output: {
path: path.resolve(__dirname, '../src/ext'),
filename: 'quickjs.js',
library: {
type: 'module'
},
module: true
},
experiments: {
outputModule: true
},
resolve: {
extensions: ['.mjs', '.js'],
fallback: {
'path': false,
'fs': false,
'crypto': false
}
},
plugins: [
new webpack.DefinePlugin({
'process.env.QTS_DEBUG': JSON.stringify(false),
'process.env.NODE_ENV': JSON.stringify('production')
})
],
module: {
rules: [
{
test: /\.m?js$/,
resolve: {
fullySpecified: false,
},
},
],
},
optimization: {
minimize: false
},
performance: {
hints: false,
maxAssetSize: 2 * 1024 * 1024,
maxEntrypointSize: 2 * 1024 * 1024,
},
};

View file

@ -4,58 +4,119 @@ description: Keyboard Shortcuts and Mouse Controls
# Controls # Controls
| Key | Action | Notes | ## Layer Navigation
| ---------------- | ----------------------------------------- | --------------------------------------- |
| e | Device Dialog | Select and customize devices | | Key | Action | Notes |
| o | CNC Tool Dialog | CNC mode only | | --- | ------ | ----- |
| c | Grid.Local Dialog | Find GridBot devices on your network | | ` (backtick) | Show layer 0 | First layer |
| q | Preference Dialog | Change application behaviors | | 1-9 | Show layers at 10-90% | In 10% increments |
| l | Work Profile Dialog | Load, Save, Rename work profiles | | 0 | Show all layers (100%) | Show maximum layer |
| | | | | Meta+Up | Next layer | Navigate up one layer |
| a | Arrange | Switch to layout view from other views | | Meta+Down | Previous layer | Navigate down one layer |
| | | or perform an automatic layout when | | [shift + wheel] | Cycle through layers | Preview mode only |
| | | already in layout view | | v | Toggle single slice view | Preview mode: toggle single vs range |
| s | Slice Object | Slice workspace objects |
| p | Preview Paths | Do route planning for sliced objects | ## View Navigation
| g | CNC Animation | Enter milling animation mode |
| x | Export Paths as GCode, SVG, DXF | Produce downloadable files | | Key | Action | Notes |
| | | | | --- | ------ | ----- |
| i | File Import Dialog | | | h | Home View | Default 45° view |
| r | Recent Files Dialog | | | t | Top View | Top-down view |
| | | | | f | Front View | Direct front side-view |
| h | Home View | Default 45 degree view of objects | | b | Back View | Direct back side-view |
| t | Top View | Top-down view of objects | | z | Reset View | Default zoom and home |
| f | Front View | Direct side-view (front) | | F | Fit View to Contents | Zoom to fit all objects |
| z | Reset View | Default zoom and Home | | `<` | Previous Side View | Cycle: left → back → right → front |
| < > | Cycle Side Views | Front, Right, Back, Left | | `>` | Next Side View | Cycle: front → right → back → left |
| v | Focus on Selection (arrange mode) | Camera points to object center | | v | Focus on Selection | Arrange mode: center camera on object |
| v | Toggle Single Slice View (preview mode) | Toggle single vs range of slices |
| | | | ## File Operations
| \~,1-9,0 | Select layer in 10% increments | Slice and Preview modes |
| | | | | Key | Action | Notes |
| d | Duplicate Selected Objects | Create a copy of selected objects | | --- | ------ | ----- |
| m | Mirror Selected Objects | Mirror vertices of selected objects | | i | File Import Dialog | Import 3D models |
| \[delete] | Delete Selected Objects | Remove selected object from workspace | | r | Recent Files Dialog | Open recently used files |
| \[arrow] | Rotate 90 degrees | Rotate selected object |
| \[shift + arrow] | Rotate 5 degrees | Rotate selected object | ## Workflow Actions
| \[ctrl + a] | Select All Objects | Select all objects |
| | | | | Key | Action | Notes |
| \[shit + click] | Toggle Selection | Select or Deselect a single object | | --- | ------ | ----- |
| \[shift + wheel] | Cycle through layers (preview mode) | Use mouse wheel as range slider | | s / S | Slice Object | Process workspace objects |
| \[meta + drag] | Pan View | Move focus of camera | | p / P | Preview Paths | Route planning (hidden in SLA mode) |
| \[right + drag] | Pan View | | | g | CNC Animation | Enter milling animation mode (CNC only) |
| \[left + drag] | Rotate | Move camera around a focal point | | x / X | Export | Export as GCode, SVG, or DXF |
| \[mouse wheel] | Zoom | Move camera closer / farther from focus | | a | Arrange | Switch to ARRANGE view, or auto-layout if already in ARRANGE |
| \[middle + drag] | Zoom | |
| | | | ## Object Manipulation
| \[ctrl + click] | Align face of selected object to platform | Rotate object face toward work surface |
| \[meta + click] | Align face of selected object to platform | | | Key | Action | Notes |
| | | | | --- | ------ | ----- |
| \[shift + z] | Reset all preferences to stock | Reload page afterward | | d | Duplicate Selected | Create copy of selected objects |
| | | | | m | Mirror Selected | Mirror vertices of selected objects |
| **Mouse** | **FDM Support Controls** | **Version Notes** | | O | Manual Rotation Input | Enter rotation values manually |
| \[left click] | Toggle add / remove column | | | [delete] | Delete Selected | Remove selected objects |
| \[ctrl + drag] | Erase support columns | 2.7+ | | [ctrl + a] / [cmd + a] | Select All | Select all objects in workspace |
| \[cmd + drag] | Erase support columns | 2.7+ | | [shift + click] | Toggle Selection | Select or deselect individual object |
| \[opt + drag] | Draw on support columns | 2.7+ |
| \[alt + drag] | Draw on support columns | 2.7+ | ## Rotation & Movement
| Key | Action | Notes |
| --- | ------ | ----- |
| [arrow] | Rotate 90° | Arrow keys rotate on respective axes |
| [shift + arrow] | Rotate 5° | Fine rotation control |
| [alt + arrow] | Move 5mm | Move object in X/Y plane |
## Rendering Modes
| Key | Action | Notes |
| --- | ------ | ----- |
| w | Toggle Ghost Rendering | Cycle between solid and semi-transparent |
| W | Toggle Wireframe | Cycle between solid and wireframe |
| Ctrl+W / Cmd+W | Toggle Edge Rendering | Show/hide model edges |
## Settings & Dialogs
| Key | Action | Notes |
| --- | ------ | ----- |
| e | Device Dialog | Select and customize devices |
| o | Tool Dialog | CNC mode only |
| q | Preferences Dialog | Change application behaviors |
| l | Work Profile Dialog | Load, save, rename work profiles |
| ? | Help Dialog | Show help and documentation |
| C | Refresh Catalog | Reload device catalog |
## Workspace Management
| Key | Action | Notes |
| --- | ------ | ----- |
| Ctrl+S | Save Settings | Save current settings |
| Cmd+S | Save Workspace | Save workspace state (macOS) |
| Cmd+L | Restore Workspace | Restore saved workspace (macOS) |
| Z | Reset All Preferences | Clear all settings (requires confirmation) |
## Mouse Controls
### General Navigation
| Input | Action | Notes |
| ----- | ------ | ----- |
| [left + drag] | Rotate View | Orbit camera around focus point |
| [right + drag] | Pan View | Move camera focus |
| [meta + drag] | Pan View | Alternative pan (macOS) |
| [middle + drag] | Zoom | Mouse wheel click + drag |
| [mouse wheel] | Zoom | Scroll to zoom in/out |
### Object Interaction
| Input | Action | Notes |
| ----- | ------ | ----- |
| [ctrl + click] | Lay Flat | Rotate clicked face toward platform |
| [meta + click] | Lay Flat | Alternative (macOS) |
### FDM Support Mode
| Input | Action | Notes |
| ----- | ------ | ----- |
| [left click] | Toggle Support Column | Add or remove support column |
| [ctrl + drag] / [cmd + drag] | Erase Columns | Remove support columns |
| [alt + drag] / [opt + drag] | Draw Columns | Add support columns |

View file

@ -1,7 +1,5 @@
self.kiri.load(api => { self.kiri.load(api => {
console.log('BAMBU MODULE RUNNING');
const { uc: ui } = api; const { uc: ui } = api;
const { $, h } = api.web; const { $, h } = api.web;
const defams = ";; DEFINE BAMBU-AMS "; const defams = ";; DEFINE BAMBU-AMS ";
@ -1232,7 +1230,7 @@ self.kiri.load(api => {
if (!bound) { if (!bound) {
return; return;
} }
if (devsel.extras?.bbl && !api.ui.deviceSave.disabled) { if (devsel.extras?.bbl && !api.ui.devices.save.disabled) {
device = devsel; device = devsel;
printers = devsel.extras.bbl; printers = devsel.extras.bbl;
bound.bblman.classList.remove('hide'); bound.bblman.classList.remove('hide');
@ -1255,7 +1253,7 @@ self.kiri.load(api => {
} }
function prep_export(gen3mf, gcode, info, settings) { function prep_export(gen3mf, gcode, info, settings) {
if (!settings.device.extras?.bbl || api.ui.deviceSave.disabled) { if (!settings.device.extras?.bbl || api.ui.devices.save.disabled) {
$('bambu-output').style.display = 'none'; $('bambu-output').style.display = 'none';
return; return;
} }
@ -1327,4 +1325,4 @@ self.kiri.load(api => {
setInterval(monitor_keepalive, 5000); setInterval(monitor_keepalive, 5000);
let bblapi = api.bambu = { send, prep_export }; let bblapi = api.bambu = { send, prep_export };
}); }, 'Bambu');

View file

@ -20,4 +20,4 @@ self.kiri.load(api => {
} }
}; };
}); });
}); }, 'Developer');

View file

@ -1,6 +1,6 @@
{ {
"name": "grid-apps", "name": "grid-apps",
"version": "4.5.0", "version": "4.6.0",
"description": "grid.space 3d slicing & modeling tools", "description": "grid.space 3d slicing & modeling tools",
"author": "Stewart Allen <sa@grid.space>", "author": "Stewart Allen <sa@grid.space>",
"license": "MIT", "license": "MIT",
@ -35,12 +35,12 @@
"node": ">=22.0.0" "node": ">=22.0.0"
}, },
"dependencies": { "dependencies": {
"@docusaurus/plugin-client-redirects": "^3.9.0", "@docusaurus/plugin-client-redirects": "3.9.1",
"@fortawesome/fontawesome-free": "^6.1.1", "@fortawesome/fontawesome-free": "^6.1.1",
"@gridspace/app-server": "^0.0.20", "@gridspace/app-server": "^0.0.20",
"@gridspace/basic-ftp": "github:gridspace/basic-ftp#v5.0.5-gridspace", "@gridspace/basic-ftp": "github:gridspace/basic-ftp#v5.0.5-gridspace",
"@gridspace/net-level-client": "^0.2.3", "@gridspace/net-level-client": "^0.2.3",
"@gridspace/raster-path": "^1.0.8", "@gridspace/raster-path": "^1.1.1",
"@tracespace/parser": "^5.0.0-next.0", "@tracespace/parser": "^5.0.0-next.0",
"@tweenjs/tween.js": "^16.6.0", "@tweenjs/tween.js": "^16.6.0",
"aedes": "^0.51.3", "aedes": "^0.51.3",
@ -60,6 +60,7 @@
"mqtt": "^5.10.3", "mqtt": "^5.10.3",
"npm": "^11.4.2", "npm": "^11.4.2",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"quickjs-emscripten": "^0.20.0",
"react-responsive-carousel": "^3.2.23", "react-responsive-carousel": "^3.2.23",
"serve-static": "^2.2.0", "serve-static": "^2.2.0",
"three": "^0.182.0", "three": "^0.182.0",
@ -69,8 +70,8 @@
"ws": "^8.18.2" "ws": "^8.18.2"
}, },
"devDependencies": { "devDependencies": {
"@docusaurus/core": "^3.9.0", "@docusaurus/core": "3.9.1",
"@docusaurus/preset-classic": "^3.9.0", "@docusaurus/preset-classic": "3.9.1",
"@electron/notarize": "^3.0.0", "@electron/notarize": "^3.0.0",
"copy-webpack-plugin": "^12.0.2", "copy-webpack-plugin": "^12.0.2",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
@ -104,7 +105,7 @@
"docs-build": "docusaurus build --config conf/docusaurus.config.js", "docs-build": "docusaurus build --config conf/docusaurus.config.js",
"docs-check": "prettier --config ./conf/prettier.config.js ./docs --check", "docs-check": "prettier --config ./conf/prettier.config.js ./docs --check",
"docs-dev": "docusaurus start --config conf/docusaurus.config.js", "docs-dev": "docusaurus start --config conf/docusaurus.config.js",
"docs-serve": "docusaurus serve --config conf/docusaurus.config.js", "docs-serve": "docusaurus serve --config conf/docusaurus.config.js --port 4004",
"dryrun": "gs-app-server --debug --single --dryrun", "dryrun": "gs-app-server --debug --single --dryrun",
"dryrun-prod": "gs-app-server --single --dryrun", "dryrun-prod": "gs-app-server --single --dryrun",
"mac-verify": "spctl --assess -vv --type install dist/*/*.app", "mac-verify": "spctl --assess -vv --type install dist/*/*.app",
@ -120,7 +121,8 @@
"start-ddb": "npm run prebuild && electron . --devel --debugg", "start-ddb": "npm run prebuild && electron . --devel --debugg",
"start-dev": "npm run prebuild prod && electron . --devel", "start-dev": "npm run prebuild prod && electron . --devel",
"start": "npm run prebuild && electron .", "start": "npm run prebuild && electron .",
"webpack-ext": "npm run webpack-three && npm run webpack-zip", "webpack-ext": "npm run webpack-three && npm run webpack-zip && npm run webpack-qjs",
"webpack-qjs": "npx webpack --config bin/webpack-quickjs-esm.js",
"webpack-src": "node bin/esbuild.config.mjs", "webpack-src": "node bin/esbuild.config.mjs",
"webpack-three": "npx webpack --config bin/webpack-three-esm.js", "webpack-three": "npx webpack --config bin/webpack-three-esm.js",
"webpack-zip": "npx webpack --config bin/webpack-jszip-esm.js" "webpack-zip": "npx webpack --config bin/webpack-jszip-esm.js"

View file

@ -676,8 +676,8 @@ export const config = {
// default # of decimal places in generated gcode // default # of decimal places in generated gcode
gcode_decimals: 4, gcode_decimals: 4,
// heal disjoint polygons in slicing (experimental) // heal disjoint polygons in slicing (experimental)
bridgeLineGapDistance: 0.05, bridgeLineGapDistance: 0.05, // may no longer be used
bridgeLineGapDistanceMax: 25, bridgeLineGapDistanceMax: 3,
// Bounds default margin nearTo // Bounds default margin nearTo
// Polygon.offset mindist2 offset precision // Polygon.offset mindist2 offset precision
precision_offset: 0.05, precision_offset: 0.05,

View file

@ -2,6 +2,7 @@
import { base } from './base.js'; import { base } from './base.js';
import { newPoint } from './point.js'; import { newPoint } from './point.js';
import { newPolygon } from './polygon.js';
/** /**
* Emit each element in array based on next closest endpoint * Emit each element in array based on next closest endpoint
@ -18,23 +19,26 @@ export function tip2tipEmit(array, startPoint, emitter) {
for (;;) { for (;;) {
found = null; found = null;
mindist = Infinity; mindist = Infinity;
array.forEach(function (el) { for (let el of array) {
if (el.delete) return; if (el.delete) continue;
dist = startPoint.distTo2D(el.first); dist = startPoint.distTo2D(el.first);
if (dist < mindist) { if (dist < mindist) {
found = { el: el, first: el.first, last: el.last }; found = { el: el, first: el.first, last: el.last };
mindist = dist; mindist = dist;
} }
// when heads is set, it's first to first
// not first/last to closest first/last
if (el.heads) continue;
dist = startPoint.distTo2D(el.last); dist = startPoint.distTo2D(el.last);
if (dist < mindist) { if (dist < mindist) {
found = { el: el, first: el.last, last: el.first }; found = { el: el, first: el.last, last: el.first };
mindist = dist; mindist = dist;
} }
}); }
if (found) { if (found) {
found.el.delete = true; found.el.delete = true;
startPoint = found.last; startPoint = found.last;
emitter(found.el, found.first, ++count); emitter(found.el, found.first, ++count, mindist);
} else { } else {
break; break;
} }
@ -42,6 +46,38 @@ export function tip2tipEmit(array, startPoint, emitter) {
return startPoint; return startPoint;
} }
/**
* Emit each element in array based on next closest endpoint
* Uses greedy nearest-neighbor algorithm to minimize travel distance
* When polygons are close, a new polygon is injected that joins them
* and the next polygon is reversed so that tips would merge on output
* @param {Array} paths - Array of elements with { first, last } Point properties
* @param {Point} startPoint - Starting point for path ordering
* @param {Number} mergeDist - threshold for merging Polygons
* @returns {Array} reduced merged Polygon array
*/
export function tip2tipJoin(paths, startPoint, mergeDist) {
let newp = [];
tip2tipEmit(paths.map(path => ({
path,
last: path.last(),
first: path.first()
})), startPoint, (next, point, count, dist) => {
if (next.last === point) {
next.path.reverse();
}
if (newp.length && dist < mergeDist) {
let lastpath = newp.peek();
newp.push(newPolygon().addPoints([
lastpath.last(),
next.path.first()
]));
}
newp.push(next.path);
});
return newp;
}
/** /**
* Emit polygons in optimal order based on nearest point * Emit polygons in optimal order based on nearest point
* Like tip2tipEmit but accepts polygons where next closest point can be anywhere * Like tip2tipEmit but accepts polygons where next closest point can be anywhere
@ -691,65 +727,10 @@ export function arcToPath(start, end, arcdivs = 24, opts) {
return arr return arr
} }
/**
* Dynamically growing Float32Array with efficient memory management
* Used for accumulating vertex/normal data when final size is unknown
* Automatically expands when capacity reached, optimizes final output
* @class
*/
export class FloatPacker {
/**
* Create new float packer with initial size
* @param {number} size - Initial array size
* @param {number} [factor=1.2] - Growth factor when expanding (clamped to max 1.2)
*/
constructor(size, factor) {
this.size = size;
this.factor = Math.min(factor || 1.2, 1.1);
this.array = new Float32Array(size);
this.pos = 0;
}
/**
* Push multiple float values onto array
* Automatically expands array if needed
* @param {...number} values - Float values to append
*/
push() {
const array = this.array;
const size = this.size;
const args = arguments.length;
if (this.pos + args >= size) {
let nusize = ((size * this.factor) | 0) + args;
let nuarray = new Float32Array(nusize);
nuarray.set(array);
this.array = nuarray;
this.size = nusize;
}
for (let i = 0; i < args; i++) {
array[this.pos++] = arguments[i];
}
}
/**
* Get final array trimmed to actual size
* Uses subarray (view) if >90% full, otherwise copies to save memory
* @returns {Float32Array} Array containing only pushed values
*/
finalize() {
if (this.pos / this.size >= 0.9) {
return this.array.subarray(0, this.pos);
} else {
return this.array.slice(0, this.pos);
}
}
}
export const paths = { export const paths = {
tip2tipEmit, tip2tipEmit,
poly2polyEmit, poly2polyEmit,
shapeToPath, shapeToPath,
pointsToPath, pointsToPath,
pathTo3D, pathTo3D
FloatPacker
} }

View file

@ -877,6 +877,7 @@ class Point {
const { x, y } = this; const { x, y } = this;
this.x = x * Math.cos(angle) - y * Math.sin(angle); this.x = x * Math.cos(angle) - y * Math.sin(angle);
this.y = y * Math.cos(angle) + x * Math.sin(angle); this.y = y * Math.cos(angle) + x * Math.sin(angle);
return this;
} }
} }

326
src/geo/poly/arcs.js Normal file
View file

@ -0,0 +1,326 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
"use strict";
import { base, util } from '../base.js';
import { newPoint } from '../point.js';
/**
* Arc detection utility functions for polygons.
* These utilities detect and annotate circular arc sequences in polygon points.
*/
/**
* Calculate the center point of a circle defined by three points.
* Using perimeter length, finds 3 equally spaced points to compute circle center.
*
* @param {Polygon} poly - polygon assumed to be roughly circular
* @returns {Point} center point of the circle
*/
export function calcCircleCenter(poly) {
let pe = poly.perimeter(),
pe1 = pe / 3, // point 2
pe2 = pe1 * 2, // point 3
p = poly.points,
l = p.length,
i = 1,
td = 0,
lp = p[0], // first point
ap = [ lp ]; // array of 3 points
while (i < l) {
let np = p[i++];
let d = lp.distTo2D(np);
td += d;
if (ap.length === 1 && td >= pe1) {
ap.push(np);
} else if (ap.length === 2 && (td >= pe2 || i >= l)) {
ap.push(np);
break;
}
lp = np;
}
let center = util.center2d(...ap);
return newPoint(center.x, center.y, p[0].z, null);
}
/**
* Iterate over polygon points and find sequences of 5 or more points
* with a common center point and collect as midpoint/radius.
*
* @param {Polygon} poly - polygon to analyze
* @param {Object} opt - options
* @param {number} opt.tolerance - center matching tolerance (default 1e-2)
* @param {boolean} opt.inside - require centers be inside polygon (default true)
* @returns {Array} array of arc center records
*/
export function findArcCenters(poly, opt = {}) {
if (poly.length < 6) return [];
let tolerance = opt.tolerance || 1e-2,
inside = opt.inside ?? true,
seq = poly.points.slice(),
util = base.util;
if (poly.isClosed()) seq.appendAll(seq.slice(0,5));
let recs = [], // accumulated arc points
cand = []; // candidate center array
for (let pos=3; pos<seq.length; pos++) {
let next = util.circleCenter(...seq.slice(pos-3, pos));
let prev = cand.peek();
if (inside && !newPoint(next.x,next.y).inPolygon(poly)) {
// require point be inside current polygon
next = null;
}
if (next && !prev) {
// seed candidate list
cand.push(next);
continue;
}
if (!next) {
// next is a bust, reset candidate list
cand.length = 0;
continue;
}
if (util.dist2D(next,prev) < tolerance) {
// add new candidate
cand.push(next);
continue;
} else if (cand.length >= 3) {
// emit record on 5 points
recs.push(cand.peek());
}
// reset candidate array
cand = [ next ];
}
if (cand.length >= 3) {
recs.push(cand.peek());
}
// filter dups
recs.sort((a,b) => {
return util.dist2D(a,b);
});
recs = recs.filter((r,i) => {
if (i > 0) {
return util.dist2D(r, recs[i-1]) < tolerance*10 ? null : r;
} else {
return r;
}
});
return recs;
}
/**
* Detect and annotate arc sequences in polygon points.
* When an arc is detected, the first point is annotated with arc metadata
* and intermediate points remain in the array but should be skipped during emission.
*
* @param {Polygon} poly - polygon to analyze (will be mutated with arc annotations)
* @param {Object} opts - detection options
* @param {number} opts.tolerance - arc detection tolerance (default 0.1)
* @param {number} opts.arcRes - arc resolution in degrees (default 1)
* @param {number} opts.minPoints - minimum points to consider an arc (default 4)
* @returns {Polygon} this polygon with arc annotations
*/
export function detectArcs(poly, opts = {}) {
const {
tolerance = 0.1,
arcRes = 1,
minPoints = 4
} = opts;
const points = poly.points;
const length = points.length;
const arcResRadians = arcRes * (Math.PI / 180);
if (length < minPoints) {
return poly;
}
// Clear any existing arc annotations
for (let p of points) {
delete p.arc;
}
let i = 0;
// Process all points except we can't start an arc at the last point
// because we need at least minPoints to form an arc
while (i < length - minPoints + 1) {
// Try to detect arc starting at position i
const arcData = detectArcAt(poly, i, points, length, tolerance, arcResRadians, minPoints);
if (arcData) {
// Annotate the starting point
points[i].arc = arcData;
// Skip past the arc points
i += arcData.skip + 1; // +1 to move to point after arc end
} else {
i++;
}
}
return poly;
}
/**
* Try to detect an arc starting at the given index.
* Uses a greedy approach: grow the arc as long as possible, then validate.
*
* @private
* @param {Polygon} poly - polygon being analyzed
* @param {number} startIdx - starting index
* @param {Point[]} points - polygon points
* @param {number} length - points length
* @param {number} tolerance - detection tolerance
* @param {number} arcRes - arc resolution
* @param {number} minPoints - minimum points for arc
* @returns {Object|null} arc data or null if no arc detected
*/
function detectArcAt(poly, startIdx, points, length, tolerance, arcRes, minPoints) {
// Greedy approach: collect as many points as possible that might form an arc
let candidates = [];
for (let idx = startIdx; idx < length; idx++) {
const p1 = points[idx];
// Last point - add and done
if (idx >= length - 1) {
candidates.push(p1);
break;
}
const p2 = points[idx + 1];
// Skip duplicate points
if (p1.distTo2D(p2) < 0.001) {
break;
}
candidates.push(p1);
// Stop if we've collected enough to test
if (candidates.length >= minPoints) {
// Try to validate the arc with current candidates
const arcData = validateArc(poly, candidates, tolerance, arcRes, minPoints);
if (!arcData) {
// Current set doesn't form valid arc, back up one point
candidates.pop();
break;
}
}
}
// Final validation with all collected candidates
return validateArc(poly, candidates, tolerance, arcRes, minPoints);
}
/**
* Validate if a set of points forms a valid arc.
*
* @private
* @param {Polygon} poly - polygon being analyzed
* @param {Point[]} arcPoints - candidate arc points
* @param {number} tolerance - detection tolerance
* @param {number} arcRes - arc resolution
* @param {number} minPoints - minimum points for arc
* @returns {Object|null} arc data or null if invalid
*/
function validateArc(poly, arcPoints, tolerance, arcRes, minPoints) {
if (arcPoints.length < minPoints) {
return null;
}
// Calculate center using well-distributed points
const center = findBestCenter(arcPoints, tolerance);
if (!center) {
return null;
}
// Check if all points lie on the circle within tolerance
let maxRadiusError = 0;
let sumRadiusError = 0;
for (let i = 0; i < arcPoints.length; i++) {
const p = arcPoints[i];
const radius = Math.hypot(p.x - center.x, p.y - center.y);
const radiusError = Math.abs(radius - center.r);
maxRadiusError = Math.max(maxRadiusError, radiusError);
sumRadiusError += radiusError;
// Hard limit on any single point
if (radiusError > tolerance * 2) {
return null;
}
}
// Check average error is reasonable
const avgRadiusError = sumRadiusError / arcPoints.length;
if (avgRadiusError > tolerance) {
return null;
}
// Check angular resolution (don't want points too far apart)
for (let i = 0; i < arcPoints.length - 1; i++) {
const curr = arcPoints[i];
const next = arcPoints[i + 1];
const dist = curr.distTo2D(next);
const radius = Math.hypot(curr.x - center.x, curr.y - center.y);
if (radius > 0) {
const angle = 2 * Math.asin(Math.min(1, dist / (2 * radius)));
if (Math.abs(angle) > arcRes) {
return null;
}
}
}
// Determine arc direction
const p0 = arcPoints[0];
const p1 = arcPoints[Math.min(1, arcPoints.length - 1)];
const vec1 = { x: p1.x - p0.x, y: p1.y - p0.y };
const vec2 = { x: center.x - p0.x, y: center.y - p0.y };
const cross = vec1.x * vec2.y - vec1.y * vec2.x;
const clockwise = cross < 0;
return {
center: newPoint(center.x, center.y, p0.z),
clockwise,
skip: arcPoints.length - 1
};
}
/**
* Find the best-fit center for a set of arc points.
*
* @private
* @param {Point[]} arcPoints - candidate arc points
* @param {number} tolerance - detection tolerance
* @returns {Object|null} center with x, y, r properties or null
*/
function findBestCenter(arcPoints, tolerance) {
const len = arcPoints.length;
// Use 3 well-spaced points for initial center calculation
let idx1 = 0;
let idx2 = Math.floor(len / 2);
let idx3 = len - 1;
// If first and last are very close (near-circle), use different points
if (arcPoints[idx1].distTo2D(arcPoints[idx3]) < tolerance * 2) {
idx1 = Math.floor(len * 0.25);
idx2 = Math.floor(len * 0.5);
idx3 = Math.floor(len * 0.75);
}
const center = util.center2d(
arcPoints[idx1],
arcPoints[idx2],
arcPoints[idx3],
1
);
if (!center || center.hasNaN?.() || !isFinite(center.r)) {
return null;
}
return center;
}

280
src/geo/poly/geo.js Normal file
View file

@ -0,0 +1,280 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
"use strict";
import { base, config, util } from '../base.js';
import { calc_normal, calc_vertex } from '../paths.js';
import { newPolygon, slopeDiff } from '../polygon.js';
/**
* Geometric utility functions for polygons.
* These are extracted from the Polygon class to improve modularity.
* All functions take a polygon as the first parameter.
*/
/**
* Remove points that are closer than `dist` to the previous point.
* Returns a new polygon with filtered points, or null if result would have < 2 points.
*
* @param {Polygon} poly - source polygon
* @param {number} dist - minimum distance between points (defaults to precision_merge)
* @returns {Polygon|null} new filtered polygon or null
*/
export function debur(poly, dist) {
if (poly.length < 2) {
return null;
}
const pa = poly.points,
pln = pa.length,
open = poly.open,
newp = newPolygon().copyZ(poly.getZ()),
min = dist || base.config.precision_merge;
let lo;
newp.push(lo = pa[0]);
for (let i = 1; i < pln; i++) {
if (lo.distTo2D(pa[i]) >= min) {
newp.push(lo = pa[i]);
}
}
newp.open = open;
newp.parent = poly.parent;
if (newp.length < 2) {
return null;
}
return newp;
}
/**
* Calculate miter joins for polygon points where angle exceeds 90 degrees.
* Returns either the original polygon or a new one with miter adjustments.
*
* @param {Polygon} poly - source polygon
* @param {boolean} debug - debug flag
* @returns {Polygon} polygon with miters applied
*/
export function miter(poly, debug) {
if (poly.length < 3) return poly;
const slo = [],
pa = poly.points,
pln = pa.length,
open = poly.open;
let last;
for (let i = 1; i < pln; i++) {
slo.push(pa[i - 1].slopeTo(last = pa[i]));
}
if (!open) {
slo.push(last.slopeTo(pa[0]));
}
const ang = new Array(pln).fill(0);
let redo = false;
const aln = open ? pln - 1 : pln;
for (let i = 1; i < aln; i++) {
ang[i] = slopeDiff(slo[i - 1], slo[i]);
redo |= ang[i] > 90;
}
if (!open) {
ang[0] = slopeDiff(slo[pln - 1], slo[0]);
redo |= ang[pln - 1] > 90;
redo |= ang[0] > 90;
}
if (redo) {
const newp = newPolygon().copyZ(poly.z);
newp.open = open;
for (let i = 0; i < pln; i++) {
const p = pa[(i + pln) % pln];
const d = ang[(i + pln) % pln];
if (d > 179) {
const s = slo[(i + pln) % pln];
const pp = pa[(i + pln - 1) % pln];
const ps = slo[(i + pln - 1) % pln];
newp.push(p.follow(p.slopeTo(pp).normal(), 0.001));
newp.push(p.follow(s.clone().normal().invert(), 0.001));
} else if (d > 90) {
const s = slo[(i + pln) % pln];
const pp = pa[(i + pln - 1) % pln];
const ps = slo[(i + pln - 1) % pln];
newp.push(p.follow(p.slopeTo(pp), 0.001));
newp.push(p.follow(s, 0.001));
} else {
p.parent = newp;
newp.push(p);
}
}
return newp;
}
return poly;
}
/**
* Create convex hull from points and add to polygon (mutates polygon).
* Only used in fdm/slice.js for enclosing supports.
*
* @param {Polygon} poly - target polygon (will be mutated)
* @param {Point[]} points - points to form convex hull
* @returns {Polygon} the mutated polygon
*/
export function createConvexHull(poly, points) {
function removeMiddle(a, b, c) {
let cross = (a.x - b.x) * (c.y - b.y) - (a.y - b.y) * (c.x - b.x);
let dot = (a.x - b.x) * (c.x - b.x) + (a.y - b.y) * (c.y - b.y);
return cross < 0 || cross == 0 && dot <= 0;
}
points.sort(function(a, b) {
return a.x != b.x ? a.x - b.x : a.y - b.y;
});
let n = points.length;
let hull = [];
for (let i = 0; i < 2 * n; i++) {
let j = i < n ? i : 2 * n - 1 - i;
while (hull.length >= 2 && removeMiddle(hull[hull.length - 2], hull[hull.length - 1], points[j]))
hull.pop();
hull.push(points[j]);
}
hull.pop();
poly.addPoints(hull);
return poly;
}
/**
* Find all intersections of a line segment with polygon edges.
* Results are sorted by distance from lp1.
*
* @param {Polygon} poly - polygon to test
* @param {Point} lp1 - line segment start
* @param {Point} lp2 - line segment end
* @param {boolean} deep - recurse into inner polygons
* @returns {Point[]} array of intersection points
*/
export function intersections(poly, lp1, lp2, deep) {
let list = [];
poly.forEachSegment(function(pp1, pp2, ip1, ip2) {
let int = util.intersect(lp1, lp2, pp1, pp2, base.key.SEGINT, false);
if (int) {
list.push(int);
pp1.pos = ip1;
pp2.pos = ip2;
}
});
if (deep && poly.inner) {
poly.inner.forEach(p => {
let ints = intersections(p, lp1, lp2);
if (ints) list.appendAll(ints);
});
}
list.sort(function(p1, p2) {
return util.distSq(lp1, p1) - util.distSq(lp1, p2);
});
return list;
}
/**
* Walk points noting z deltas and smoothing z sawtooth patterns.
* Used to smooth low-rez surface contouring (mutates polygon).
*
* @param {Polygon} poly - polygon to smooth (mutated)
* @param {number} passes - number of smoothing passes
* @returns {Polygon} the mutated polygon
*/
export function refine(poly, passes = 0) {
for (let j = 0; j < passes; j++) {
let points = poly.points,
length = points.length,
sn = [], // segment normals
vn = []; // vertex normals
for (let i = 0; i < length; i++) {
let p1 = points[i];
let p2 = points[(i + 1) % length];
sn.push(calc_normal(p1, p2));
}
for (let i = 0; i < length; i++) {
let n1 = sn[(i + length - 1) % length];
let n2 = sn[i];
let vi = calc_vertex(n1, n2, 1);
vn.push(vi);
let vl = Math.abs(1 - vi.vl).round(2);
// vl should be close to zero on smooth / continuous curves
// factoring out hard turns, we smooth the z using the weighted
// z values of the points before and after the current point
if (vl === 0) {
let p0 = points[(i + length - 1) % length];
let p1 = points[i];
let p2 = points[(i + 1) % length];
p1.z = (p0.z + p2.z + p1.z) / 3;
}
}
}
return poly;
}
/**
* Test if two polygons are essentially equivalent in shape and position.
* Checks area, bounds, circularity, and point-to-segment distances.
*
* @param {Polygon} poly1 - first polygon
* @param {Polygon} poly2 - second polygon
* @param {boolean} recurse - also check inner polygons
* @param {number} precision - tolerance for comparisons
* @returns {boolean} true if polygons are equivalent
*/
export function isEquivalent(poly1, poly2, recurse, precision) {
let area1 = Math.abs(poly1.area());
let area2 = Math.abs(poly2.area());
if (util.isCloseTo(area1, area2, precision || config.precision_poly_area) &&
poly1.bounds.equals(poly2.bounds, precision || config.precision_poly_bounds)) {
// use circularity near 1 to eliminate the extensive check below
let c1 = poly1.circularity(),
c2 = poly2.circularity();
if (Math.abs(c1 - c2) < config.precision_circularity && ((1 - c1) < config.precision_circularity)) {
return true;
}
if (recurse) {
let i, ai = poly1.inner,
bi = poly2.inner;
if (ai !== bi) {
if (ai === null || bi === null || ai.length != bi.length) {
return false;
}
for (i = 0; i < ai.length; i++) {
if (!isEquivalent(ai[i], bi[i])) {
return false;
}
}
}
}
let exit = true,
pointok,
dist,
min;
poly1.forEachPoint(i2p => {
pointok = false;
poly2.forEachSegment((i1p1, i1p2) => {
// if point is close to poly, terminate search, go to next point
if ((dist = i2p.distToLine(i1p1, i1p2)) < config.precision_poly_merge) {
return pointok = true;
}
// otherwise track min and keep searching
min = Math.min(min, dist);
});
// fail poly if one point is bad
if (!pointok) {
exit = false;
// terminate search
return true;
}
});
return exit;
}
return false;
}

275
src/geo/poly/mesh.js Normal file
View file

@ -0,0 +1,275 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
"use strict";
import { earcut as earcutLib } from '../base.js';
import { polygons as POLY } from '../polygons.js';
/**
* Mesh generation utility functions for polygons.
* These utilities convert 2D polygons into 3D meshes and geometry.
*/
/**
* Triangulate polygon using earcut library.
* Returns array of triangular polygons with preserved alignment/swap info.
*
* @param {Polygon} poly - polygon to triangulate
* @returns {Polygon[]} array of triangular polygons
*/
export function earcut(poly) {
// gather all points into a single array including inner polys
// keeping track of array offset indices for inners
let out = [];
let holes = [];
// flatten points into array for earcut()
poly.points.forEach(p => {
out.push(p.x, p.y, p.z);
});
// add hole offsets for inner polygons
if (poly.inner) {
poly.inner.forEach(p => {
holes.push(out.length / 3);
p.points.forEach(p => {
out.push(p.x, p.y, p.z);
})
});
}
// perform earcut()
let cut = earcutLib(out, holes, 3);
let ret = [];
// preserve swaps in new polys
for (let i = 0; i < cut.length; i += 3) {
let p = new (poly.constructor)();
p._aligned = poly._aligned;
p._swapped = poly._swapped;
for (let j = 0; j < 3; j++) {
let n = cut[i + j] * 3;
p.add(out[n], out[n + 1], out[n + 2]);
}
ret.push(p);
}
return ret;
}
/**
* Turn 2D polygon into a 2.5D ribbon extruded in Z.
* Returns flat array of vertices for mesh rendering.
*
* @param {Polygon} poly - polygon to extrude as ribbon
* @param {number} z - Z height (default 1)
* @param {number} zadd - Z offset (default 0)
* @param {boolean} rev - reverse winding (default false)
* @returns {number[]} flat array of triangle vertices
*/
export function ribbonZ(poly, z = 1, zadd = 0, rev) {
let clone = poly.clone().setClockwise();
let faces = [];
let points = clone.points;
let length = points.length;
if (rev) {
points = points.slice().reverse();
}
for (let i=0; i<length; i++) {
let p0 = points[i];
let p1 = points[(i + 1) % length];
faces.push(p0.x, p0.y, p0.z + zadd);
faces.push(p1.x, p1.y, p1.z + z + zadd);
faces.push(p1.x, p1.y, p0.z + zadd);
faces.push(p0.x, p0.y, p0.z + zadd);
faces.push(p0.x, p0.y, p0.z + z + zadd);
faces.push(p1.x, p1.y, p1.z + z + zadd);
}
return faces;
}
/**
* For turning a poly with an inner offset into a 3D mesh.
* Requires inner has same circularity and <= num points.
* Primarily used to make chamfers in mesh:tool.
*
* @param {Polygon} poly - polygon with exactly one inner
* @param {boolean} swap - swap winding for normals
* @returns {number[]|undefined} flat array of triangle vertices or undefined if invalid
*/
export function ribbonMesh(poly, swap) {
if (!(poly.inner && poly.inner.length === 1)) {
return undefined;
}
let outer = poly.clone().setClockwise();
let inner = poly.inner[0].clone().setClockwise();
let c0 = outer.circularity();
let c1 = inner.circularity();
let n0 = outer.points.length;
let n1 = inner.points.length;
let p0 = outer.points.slice();
let p1 = inner.points.slice();
let min = { d: Infinity, i:0, j:0 };
// find the closests two points inner/outer
for (let i=0; i<p0.length; i++) {
for (let j=0; j<p1.length; j++) {
let d = p0[i].distTo2D(p1[j]);
if (d < min.d) {
min = { d, i, j };
}
}
}
// slide the arrays until the closest points are aligned at index = 0
p0 = p0.slice(min.i).concat(p0.slice(0, min.i)); p0.push(p0[0]);
p1 = p1.slice(min.j).concat(p1.slice(0, min.j)); p1.push(p1[0]);
// walk both arrays moving to the next poly + point that forms
// the shortest line segment between the two points (inner / outer)
let faces = [];
let pi0 = 0;
let pi1 = 0;
let pp0 = p0[pi0];
let pp1 = p1[pi1];
for (;;) {
let pn0 = p0[pi0 + 1];
let pn1 = p1[pi1 + 1];
if ((!pn0 && pn1) || (pn1 && pp0.distTo2D(pn1) < pp1.distTo2D(pn0))) {
// emit and increment bottom
faces.push(pp0.x, pp0.y, pp0.z);
if (swap) {
faces.push(pp1.x, pp1.y, pp1.z);
faces.push(pn1.x, pn1.y, pn1.z);
} else {
faces.push(pn1.x, pn1.y, pn1.z);
faces.push(pp1.x, pp1.y, pp1.z);
}
pi1++;
pp1 = p1[pi1];
} else if (pn0) {
// emit and increment top
faces.push(pp0.x, pp0.y, pp0.z);
if (swap) {
faces.push(pp1.x, pp1.y, pp1.z);
faces.push(pn0.x, pn0.y, pn0.z);
} else {
faces.push(pn0.x, pn0.y, pn0.z);
faces.push(pp1.x, pp1.y, pp1.z);
}
pi0++;
pp0 = p0[pi0];
} else {
break;
}
}
return faces;
}
/**
* Extrude polygon (with inner voids) into 3D mesh.
* Supports chamfers on top and/or bottom edges.
*
* @param {Polygon} poly - polygon to extrude
* @param {number} z - Z height (default 1)
* @param {Object} opt - options
* @param {number} opt.zadd - Z offset for bottom (default 0)
* @param {number} opt.chamfer - chamfer size for both top and bottom (default 0)
* @param {number} opt.chamfer_top - chamfer size for top (overrides chamfer)
* @param {number} opt.chamfer_bottom - chamfer size for bottom (overrides chamfer)
* @returns {number[]} flat array of triangle vertices
*/
export function extrude(poly, z = 1, opt = {}) {
let earcutFaces = earcut(poly); // array of 3-point polygons
// return just the 2D face when no Z depth specified
// used primarily by mesh.sketch render()
if (z === 0) {
return earcutFaces.map(face => face.points.map(p => [ p.x, p.y, p.z ])).flat().flat();
}
let inv = z < 0;
if (inv) {
z = -z;
}
let chamfer = opt.chamfer || 0;
let chamfer_top = opt.chamfer_top || chamfer;
let chamfer_bottom = opt.chamfer_bottom || chamfer;
if (inv) {
let tmp = chamfer_top;
chamfer_top = chamfer_bottom;
chamfer_bottom = tmp;
}
let zadd = (typeof opt === 'number' ? opt : opt.zadd || 0); // z bottom
let obj = []; // flat output vertex array (float-x,float-y,float-z,...)
let top_face = earcutFaces;
let bottom_face = earcutFaces;
let z_top = z + zadd;
let z_bottom = zadd;
let z_side_top = z;
let z_side_bottom = z_bottom;
// chamfer bottom only on negative chamfer
if (chamfer < 0) {
chamfer_top = 0;
chamfer_bottom = -chamfer;
}
// create chamfers (when defined)
if (chamfer_top) {
let inset = poly.offset(chamfer_top);
if (inset.length === 1) {
inset[0].setZ(z_top);
top_face = earcut(inset[0]);
z_side_top -= chamfer_top;
let renest = POLY.renest([poly.clone(true).setZ(z_side_top), inset[0]]);
for (let rnpoly of renest) {
obj.appendAll(ribbonMesh(rnpoly, true));
}
}
}
if (chamfer_bottom) {
let inset = poly.offset(chamfer_bottom);
if (inset.length === 1) {
inset[0].setZ(0);
bottom_face = earcut(inset[0]);
z_side_top -= chamfer_top || chamfer_bottom;
z_side_bottom += chamfer_bottom;
let renest = POLY.renest([poly.clone(true).setZ(z_side_bottom), inset[0]]);
for (let rnpoly of renest) {
obj.appendAll(ribbonMesh(rnpoly, false));
}
}
}
for (let p of top_face) {
for (let point of p.points) {
obj.push(point.x, point.y, z_top);
}
}
// bottom face (reversed to reverse normals)
for (let p of bottom_face) {
for (let point of p.points.reverse()) {
obj.push(point.x, point.y, z_bottom);
}
}
// outside wall
let rib = z - chamfer_top - chamfer_bottom;
obj.appendAll(ribbonZ(poly, rib, z_side_bottom));
for (let inner of poly.inner || []) {
// inside wall(s)
obj.appendAll(ribbonZ(inner, rib, z_side_bottom, true));
}
if (inv) {
for (let i=2; i<obj.length; i+=3) {
obj[i] -= z;
}
}
return obj;
}

374
src/geo/poly/path.js Normal file
View file

@ -0,0 +1,374 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
"use strict";
import { util } from '../base.js';
import { newPoint } from '../point.js';
import { newPolygon } from '../polygon.js';
import { newSlopeFromAngle } from '../slope.js';
import { polygons as POLY } from '../polygons.js';
/**
* Toolpath and CNC utility functions for polygons.
* These utilities handle path generation, segmentation, and CNC-specific operations.
*/
/**
* Generate a trace path around the inside of a polygon including inner polys.
* Returns the noodle and the remainder of the polygon with the noodle removed.
* TODO: relocate this code to fdm/post.js
*
* @param {Polygon} poly - polygon to trace
* @param {number} width - trace width
* @returns {Object} { noodle: Polygon[], remain: Polygon[] }
*/
export function noodle(poly, width) {
let clone = poly.clone(true);
let ins = clone.offset(width) ?? [];
let remain = ins.clone(true);
let nood = POLY.nest(POLY.flatten([ clone, ...ins ], [], true));
return { noodle: nood, remain };
}
/**
* Generate center crossing point cloud for thin wall filling.
* Only used for fdm thin-wall type 1 (fdm/post.js).
* TODO: relocate this code to fdm/post.js
*
* @param {Polygon} poly - polygon to analyze
* @param {number} step - step size for sampling
* @param {number} z - Z height
* @param {number} min - minimum distance threshold
* @param {number} max - maximum distance threshold
* @param {Object} opt - options
* @param {boolean} opt.lines - return lines instead of points (default false)
* @param {number} opt.mindist - minimum distance for point joining (default step * 1.5)
* @returns {Polygon[]|Point[]} array of polygons or points
*/
export function centers(poly, step, z, min, max, opt = {}) {
let cloud = [],
bounds = poly.bounds,
lines = opt.lines || false,
stepoff = step / 2,
set = [poly.points];
if (poly.inner) {
for (let inner of poly.inner) {
set.push(inner.points);
}
}
for (let y of util.lerp(bounds.miny + stepoff, bounds.maxy - stepoff, step, true)) {
let ints = [];
for (let points of set) {
let length = points.length;
for (let i = 0; i < length; i++) {
let p1 = points[i % length];
let p2 = points[(i + 1) % length];
if (
(p1.y <= y && p2.y > y) ||
(p1.y > y && p2.y <= y)
) ints.push([p1, p2]);
}
}
let cntr = [];
if (ints.length && ints.length % 2 === 0) {
for (let int of ints) {
let [p1, p2] = int;
if (p2.y < p1.y) {
let tp = p1;
p1 = p2;
p2 = tp;
}
let minx = Math.min(p1.x, p2.x);
let maxx = Math.max(p1.x, p2.x);
let miny = Math.min(p1.y, p2.y);
let maxy = Math.max(p1.y, p2.y);
let dx = p2.x - p1.x;
let dy = maxy - miny;
let pct = (y - miny) / dy;
let xpo = p1.x + pct * dx;
cntr.push(xpo);
}
}
cntr.sort((a, b) => {
return b - a;
});
let lp, eo = 0;
for (let x of cntr) {
let p = newPoint(x, y, z);
if (eo++ % 2) {
let d = lp.distTo2D(p);
if (d >= min && d <= max) {
if (lines) {
cloud.push(lp);
cloud.push(p);
} else {
cloud.push(newPoint(
(lp.x + p.x) / 2, y, z
));
}
}
} else {
lp = p;
}
}
}
for (let x of util.lerp(bounds.minx + stepoff, bounds.maxx - stepoff, step, true)) {
let ints = [];
for (let points of set) {
let length = points.length;
for (let i = 0; i < length; i++) {
let p1 = points[i % length];
let p2 = points[(i + 1) % length];
if (
(p1.x <= x && p2.x > x) ||
(p1.x > x && p2.x <= x)
) ints.push([p1, p2]);
}
}
let cntr = [];
if (ints.length && ints.length % 2 === 0) {
for (let int of ints) {
let [p1, p2] = int;
if (p2.x < p1.x) {
let tp = p1;
p1 = p2;
p2 = tp;
}
let minx = Math.min(p1.x, p2.x);
let maxx = Math.max(p1.x, p2.x);
let miny = Math.min(p1.y, p2.y);
let maxy = Math.max(p1.y, p2.y);
let dx = maxx - minx;
let dy = p2.y - p1.y;
let pct = (x - minx) / dx;
let ypo = p1.y + pct * dy;
cntr.push(ypo);
}
}
cntr.sort((a, b) => {
return b - a;
});
let lp, eo = 0;
for (let y of cntr) {
let p = newPoint(x, y, z);
if (eo++ % 2) {
let d = lp.distTo2D(p);
if (d >= min && d <= max) {
if (lines) {
cloud.push(lp);
cloud.push(p);
} else {
cloud.push(newPoint(
x, (lp.y + p.y) / 2, z
));
}
}
} else {
lp = p;
}
}
}
if (lines) {
return cloud;
}
let mindist = opt.mindist || step * 1.5;
function build(polyPoints) {
let lastp = polyPoints.last();
let minp;
let mind = Infinity;
for (let point of cloud) {
let dist = point.distTo2D(lastp);
if (dist < mindist && dist < mind) {
mind = dist;
minp = point;
}
}
if (minp) {
cloud = cloud.filter(p => p !== minp);
polyPoints.push(minp);
return true;
}
return false;
}
// join points into polys
let polys = [];
let polyPoints = [];
while (cloud.length) {
if (polyPoints.length === 0) {
polyPoints = [cloud.shift()];
polys.push(polyPoints);
continue;
}
if (build(polyPoints)) {
continue;
}
if (!polyPoints.flip) {
polyPoints.reverse();
polyPoints.flip = true;
continue;
}
if (polyPoints.length) {
polyPoints = [];
} else {
throw "whoop there it is";
}
}
return polys
.filter(polyPoints => polyPoints.length > 1)
.map(polyPoints => {
let np = newPolygon().setOpen();
for (let p of polyPoints) {
np.push(p);
}
if (np.last().distTo2D(np.first()) <= max) {
np.setClosed();
}
np = np.clean();
return np;
});
}
/**
* Split long straight lines into segments no longer than `max`.
* Returns a new polygon. Optionally `mark` points with their derived
* segment start point (hinting for thin walls), `wrap` the poly first
* point by appending to the end (adaptive walls), or stop segmentation at `maxoff`.
*
* @param {Polygon} poly - polygon to segment
* @param {number} max - maximum segment length (default 1)
* @param {boolean} mark - mark points with segment start (default false)
* @param {boolean} wrap - wrap first point to end (default false)
* @param {number} maxoff - maximum offset for segmentation (default Infinity)
* @returns {Polygon} new segmented polygon
*/
export function segment(poly, max = 1, mark = false, wrap = false, maxoff = Infinity) {
const newp = [];
const points = poly.points;
const length = points.length;
const l0 = poly.open ? length - 1 : length;
for (let i=0, p=points, l1=length, l2=l1+1; i<l0; i++) {
const p1 = p[i];
const p2 = p[(i + 1) % l1];
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const dl = Math.sqrt(dx * dx + dy * dy);
newp.push(p1);
if (mark) p1.segment = p1;
// if segment shorter than max (delta) or
// maxoff is exhausted (only segmenting up to some length)
// then skip sub-segmenting the current segment
if (dl < max || maxoff < 0) {
continue;
}
maxoff -= dl;
const div = dl / max;
const fit = div | 0;
const add = fit - 1;
const ix = dx / fit;
const iy = dy / fit;
let ox = p1.x + ix;
let oy = p1.y + iy;
for (let i=0; i<add; i++) {
newp.push(newPoint(ox, oy, (p1.z + p2.z) / 2));
ox += ix;
oy += iy;
// mark new point with first point of originating segment
if (mark) newp.peek().segment = p1;
}
}
if (newp.length > length) {
if (wrap) {
newp.push(newp[0]);
}
return newPolygon().addPoints(newp.map(p => p.clone(['segment']))).setOpenValue(poly.open);
}
return poly;
}
/**
* For any poly points closer than `dist`, replace them with their midpoint.
* Only used by cam/op-pocket.js.
*
* @param {Polygon} poly - polygon to process
* @param {number} dist - distance threshold (default 0.01)
* @returns {Polygon} new polygon with midpoints or original if no changes
*/
export function midpoints(poly, dist = 0.01) {
const newp = [];
const points = poly.points;
const length = points.length;
const l0 = poly.open ? length - 1 : length;
let mod = 0;
for (let i=0, p=points; i<l0; i++) {
const p1 = p[i];
const p2 = p[(i + 1) % length];
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const ln = Math.sqrt(dx * dx + dy * dy);
if (ln < dist) {
newp.push(p1.midPointTo(p2));
mod++;
} else {
newp.push(p1);
}
}
if (mod) {
return newPolygon().addPoints(newp.map(p => p.clone())).setOpenValue(poly.open);
}
return poly;
}
/**
* Add dogbone joints for CNC operations at sharp interior corners.
* Mutates the polygon in place. Recursively processes inner polygons.
*
* @param {Polygon} poly - polygon to modify (mutated)
* @param {number} dist - dogbone distance/size
* @param {boolean} reverse - reverse winding direction
* @returns {Polygon} the mutated polygon
*/
export function addDogbones(poly, dist, reverse) {
let open = poly.open;
let isCW = poly.isClockwise();
if (reverse || poly.parent) isCW = !isCW;
let oldpts = poly.points.slice();
let lastpt = oldpts[oldpts.length - 1];
let lastsl = lastpt.slopeTo(oldpts[0]).toUnit();
let length = oldpts.length + (open ? 0 : 1);
let newpts = [];
for (let i = 0; i < length; i++) {
let nextpt = oldpts[i % oldpts.length];
let nextsl = lastpt.slopeTo(nextpt).toUnit();
let adiff = lastsl.angleDiff(nextsl, true);
let bdiff = ((adiff < 0 ? (180 - adiff) : (180 + adiff)) / 2) + 180;
if (!open || (i > 1 && i < length)) {
if (isCW && adiff > 45) {
let newa = newSlopeFromAngle(lastsl.angle + bdiff);
newpts.push(lastpt.projectOnSlope(newa, dist));
newpts.push(lastpt.clone());
} else if (!isCW && adiff < -45) {
let newa = newSlopeFromAngle(lastsl.angle - bdiff);
newpts.push(lastpt.projectOnSlope(newa, dist));
newpts.push(lastpt.clone());
}
}
lastsl = nextsl;
lastpt = nextpt;
if (i < oldpts.length) {
newpts.push(nextpt);
}
}
poly.points = newpts;
if (poly.inner) {
poly.inner.forEach(inner => addDogbones(inner, dist, true));
}
return poly;
}

File diff suppressed because it is too large Load diff

View file

@ -61,6 +61,7 @@ const POLYS = {
offset, offset,
outer, outer,
points, points,
print,
rayIntersect, rayIntersect,
reconnect, reconnect,
renest, renest,
@ -71,6 +72,7 @@ const POLYS = {
toClipper, toClipper,
trimTo, trimTo,
union, union,
unionFaces,
xor, xor,
}; };
@ -211,6 +213,15 @@ export function renest(polygons, deep) {
return nest(flatten(polygons, [], true), deep); return nest(flatten(polygons, [], true), deep);
} }
export function print(polys, pad = ' ', buf) {
buf = buf || [];
for (let poly of polys) {
buf.push(`${pad} - ${poly.points.length}`);
if (poly.inner) print(poly.inner, pad + pad, buf);
}
return buf;
}
/** /**
* todo use clipper polytree? * todo use clipper polytree?
* *
@ -480,13 +491,89 @@ export function union(polys, minarea, all, opt = {}) {
} }
for (i=0; i<out.length; i++) { for (i=0; i<out.length; i++) {
if (out[i]) uset.push(out[i]); if (out[i]) {
if (!minarea || out[i].area() >= minarea) {
uset.push(out[i]);
}
}
} }
opt.changes = length(uset) - lpre; opt.changes = length(uset) - lpre;
return uset; return uset;
} }
/**
* Recursively process triangle soup from mesh by bucketing them
* by grid cell locality with max depth. This minimizes the number
* of progressive small unions. Results in a few unions of larger polys.
* Optimized for unioning 1000+ triangular faces from mesh slicing.
*
* @param {Polygon[]} polys - array of polygons (typically triangular faces)
* @param {number} depth - recursion depth (internal, default 0)
* @returns {Polygon[]} unioned polygons
*/
export function unionFaces(polys, depth = 0) {
if (depth < 5 && polys.length > 1000) {
// Calculate bounding box of all triangles
let minX = Infinity, maxX = -Infinity;
let minY = Infinity, maxY = -Infinity;
for (let tri of polys) {
for (let pt of tri.points) {
minX = Math.min(minX, pt.x);
maxX = Math.max(maxX, pt.x);
minY = Math.min(minY, pt.y);
maxY = Math.max(maxY, pt.y);
}
}
// Create NxN grid
const gridSize = 3;
const cellWidth = (maxX - minX) / gridSize;
const cellHeight = (maxY - minY) / gridSize;
// Partition polygons into grid cells
const grid = Array(gridSize).fill(0).map(() => Array(gridSize).fill(0).map(() => []));
for (let i = 0; i < polys.length; i++) {
const poly = polys[i];
// Use polygon centroid to assign to cell
const bounds = poly.bounds;
const cx = (bounds.minx + bounds.maxx) / 2;
const cy = (bounds.miny + bounds.maxy) / 2;
// Clamp to grid bounds
let gx = Math.floor((cx - minX) / cellWidth);
let gy = Math.floor((cy - minY) / cellHeight);
gx = Math.max(0, Math.min(gridSize - 1, gx));
gy = Math.max(0, Math.min(gridSize - 1, gy));
grid[gx][gy].push(poly);
}
// Union within each cell
const cellResults = [];
let totalInCell = 0;
for (let gx = 0; gx < gridSize; gx++) {
for (let gy = 0; gy < gridSize; gy++) {
const cellPolys = grid[gx][gy];
if (cellPolys.length > 0) {
totalInCell += cellPolys.length;
const cellUnion = unionFaces(cellPolys, depth + 1);
cellResults.push(...cellUnion);
}
}
}
// Union the cell results
polys = unionFaces(cellResults, depth + 1);
} else {
polys = union(polys, 0, true, { wasm: false });
}
return polys;
}
/** /**
* @param {Polygon} poly clipping mask * @param {Polygon} poly clipping mask
* @returns {?Polygon[]} * @returns {?Polygon[]}

View file

@ -380,7 +380,7 @@ export async function sliceZ(z, points, options = {}) {
let rval = { z, lines }; let rval = { z, lines };
if (groupFn) { if (groupFn) {
let groups = groupFn(lines, z, options); let groups = groupFn(lines, options);
if (options.xor) { if (options.xor) {
groups = polygons.xor(groups); groups = polygons.xor(groups);
} }
@ -448,12 +448,12 @@ export async function sliceZ(z, points, options = {}) {
* interesection. Eliminate used points and repeat. Unjoined lines * interesection. Eliminate used points and repeat. Unjoined lines
* are permitted and handled after all other cases are handled. * are permitted and handled after all other cases are handled.
* *
* @param {Line[]} input * @param {Line[]} input array of lines (soup)
* @param {number} [index] * @param {Object} opt options
* @returns {Array} * @returns {Array}
*/ */
export function sliceConnect(input, z, opt = {}) { export function sliceConnect(input, opt = {}) {
let { debug, both } = opt; let { both, debug, dirty, strict } = opt;
if (both) { if (both) {
if (debug) console.log('unable to connect lines sliced with "both" option'); if (debug) console.log('unable to connect lines sliced with "both" option');
@ -466,8 +466,8 @@ export function sliceConnect(input, z, opt = {}) {
output = [], output = [],
connect = [], connect = [],
emitted = 0, emitted = 0,
forks = false, forks = [], // points which contain forks
frays = false, stubs = [], // dead end points
bridge = config.bridgeLineGapDistance, bridge = config.bridgeLineGapDistance,
bridgeMax = config.bridgeLineGapDistanceMax, bridgeMax = config.bridgeLineGapDistanceMax,
p1, p2, gl; p1, p2, gl;
@ -480,11 +480,17 @@ export function sliceConnect(input, z, opt = {}) {
return p; return p;
} }
// add p2 to p1's group array
function addConnected(p1, p2) { function addConnected(p1, p2) {
if (p1 === p2) {
// console.log('drop same point connect');
return;
}
if (!p1.group) p1.group = [ p2 ]; if (!p1.group) p1.group = [ p2 ];
else p1.group.push(p2); else p1.group.addOnce(p2);
} }
// calculate and store array perimeter length
function perimeter(array) { function perimeter(array) {
if (!array.perimeter) { if (!array.perimeter) {
array.perimeter = newPolygon().addPoints(array).perimeter(); array.perimeter = newPolygon().addPoints(array).perimeter();
@ -492,142 +498,230 @@ export function sliceConnect(input, z, opt = {}) {
return array.perimeter; return array.perimeter;
} }
/** // calculate and store array area
* follow points through connected lines to form candidate output paths function area(array) {
*/ if (!array.area) {
function findNextPath(point, current, branches, depth = 1) { array.area = newPolygon().addPoints(array).area();
let path = [];
if (current) {
current.push(path);
} }
return array.area;
}
function debug_points() {
let clone = points.map(p => {
let pc = p.clone();
pc.group = p.group.slice();
return pc;
});
clone.sort((a,b) => a._key < b._key ? -1 : 1);
console.log({ points: clone });
}
/**
* construct a new path starting at point (may be a branch)
* @param {Point} point current point we're following
* @param {Point} base first point for start of all paths
* @param {Array} branches collected branches when recursing
* @param {Integer} depth recursion depth
*/
function findNextPath(point, { base, branches, depth, path } = { }) {
let marked = [];
base = base ?? point;
branches = branches ?? [];
depth = depth ?? 1;
path = path ?? [];
branches.push(path);
for (;;) { for (;;) {
// prevent point re-use // if the next point is the root, emit closed path
if (point === base && (path.length || branches.length > 1)) {
if (debug) console.log('term at start');
break;
}
// we can't pass thru a used point
if (point.del) {
if (debug) console.log('term at used point');
break;
}
// prevent passing through the same point twice in a search
point.del = true; point.del = true;
// add point to path // add point to path
path.push(point); path.push(point);
// track marked points so they can be cleared
marked.push(point);
// find unused "next" points
let links = point.group.filter(p => !p.del); let links = point.group.filter(p => !p.del);
// no need to recurse at the start if (debug) console.log({ point, depth, path, links: links.length });
if (links.length === 2 && depth === 1) {
point = links[0];
// if (debug) console.log({start_mid: point, depth});
continue;
}
// if fork in the road, follow all paths to their end if (links.length === 0) {
// and find the longest path // for dangling paths, terminate at any junction
let root = !current, nc; if (debug) console.log('term at stub or no branch')
if (links.length > 1) { break;
// if (debug) console.log('fork!', {links: links.length, depth, root}); } else if (links.length === 1) {
if (root) { // one choice is easy, keep going
current = [ path ]; point = links[0];
branches = [ ]; } else if (links.length > 1) {
} // if fork in the road, follow all paths to their end
// then find the path representing the shortest perimeter
if (debug) console.log('fork!', { base, links: links.length, depth });
if (branches.length < 500) if (branches.length < 500)
for (let p of links) { for (let p of links) {
branches.push(nc = current.slice()); findNextPath(p, {
let rpath = findNextPath(p, nc, branches, depth + 1); base,
// allow point re-use in other path searches branches,
for (let p of rpath) p.del = false; depth: depth + 1,
marked,
path: path.slice()
});
} }
// flatten and sort in ascending perimeter break;
let flat = branches.map(b => b.flat()).sort((a,b) => {
return perimeter(b) - perimeter(a);
});
let npath = flat[0];
if (debug) console.log({
root,
branches: branches.slice(),
flat, path, npath
});
if (root) {
for (let p of npath) p.del = true;
return npath;
} else {
return path;
}
// return root ? npath : path;
} else {
// choose next (unused) point
point = links[0];
}
// hit an open end or branch
if (!point || point.del) {
return path;
} }
} }
throw "invalid state"; for (let point of marked) {
point.del = false;
}
// we only return data at the start of recursion
if (depth === 1) {
// flatten and sort branch paths in ascending perimeter
branches.sort((a,b) => {
return area(b) - area(a);
});
let npath = branches[0];
if (debug) console.log({
branches,
npath,
path,
base
});
for (let p of npath) {
p.del = true;
}
return npath;
} else {
if (debug) console.log('end branch', { depth });
}
} }
// emit a polygon if it can be cleaned and still have 2 or more points // emit a polygon if it can be cleaned and still have 2 or more points
function emit(poly) { function emit(poly) {
let gap = poly.first().distTo2D(poly.last());
if (poly.open && strict && gap > bridgeMax) {
if (debug) console.log('drop', gap, poly);
return;
}
emitted += poly.length; emitted += poly.length;
if (!opt.dirty) poly = poly.clean(); if (!dirty) poly = poly.clean();
if (poly.length > 2 || true) output.push(poly); if (poly.length > 2) output.push(poly);
if (debug) console.log('xray',poly); if (debug) console.log('xray', poly);
} }
// given an array of paths, emit longest to shortest // given an array of paths, emit longest to shortest
// eliminating points from the paths as they are emitted // eliminating points from the paths as they are emitted
// shorter paths any point eliminated are eliminated as candidates. // shorter paths any point eliminated are eliminated as candidates.
function emitPath(path) { function emitPath(path) {
// test if last point is in the first point's connected list
let closed = path[0].group.indexOf(path.peek()) >= 0; let closed = path[0].group.indexOf(path.peek()) >= 0;
if (closed && path.length > 2) { if (closed && path.length > 2) {
if (debug) console.log({ closed: path.length, path }); if (debug) console.log('emit', { closed: path.length, path });
emit(newPolygon().addPoints(path)); emit(newPolygon().addPoints(path));
} else if (path.length > 1) { } else if (path.length > 2) {
let gap = path[0].distTo2D(path.peek()).round(4); let gap = path[0].distTo2D(path.peek()).round(4);
if (debug) console.log({ open: path.length, gap, path }); if (gap < bridgeMax) {
connect.push(path); connect.push(path);
}
if (debug) console.log('emit', { open: path.length, gap, path });
} else {
if (debug) console.log('emit', { drop: path });
} }
if (debug) debug_points();
} }
// create point map, unique point list and point group arrays // create point map, unique point list and point group arrays
input.forEach(function(line) { for (let line of input) {
p1 = cachedPoint(line.p1); p1 = cachedPoint(line.p1);
p2 = cachedPoint(line.p2); p2 = cachedPoint(line.p2);
addConnected(p1,p2); addConnected(p1,p2);
addConnected(p2,p1); addConnected(p2,p1);
}); }
// exclude dangling points (from lines with the same endpoints)
points = points.filter(point => point.group);
// console.log({points, forks: points.filter(p => p.group.length !== 2)}); // console.log({points, forks: points.filter(p => p.group.length !== 2)});
// for each unused point, find the longest non-intersecting path // for each unused point, find the longest non-intersecting path
for (let point of points) { for (let point of points) {
gl = point.group.length; gl = point.group.length;
forks = forks || gl > 2; if (gl > 2) forks.push(point);
frays = frays || gl < 2; if (gl < 2) stubs.push(point);
}
// show found forks and stubs in debug/xray mode
if (debug) {
console.log({ forks, stubs });
debug_points();
}
// for each dangling endpoint, try to find the closest dangling point
// in the soup which is less than the max bridging distance.
// if a candidate is found, synthesize a line between the endpoint
// and the candidate.
if (stubs.length) {
for (let i=0; i<stubs.length; i++) {
let min = { dist: Infinity };
let pi = stubs[i];
if (!pi) continue;
for (let j=1+i; j<stubs.length; j++) {
let pj = stubs[j];
if (!pj) continue;
let dist = pi.distTo2D(pj);
if (dist < min.dist) {
min.dist = dist;
min.p = pj;
min.j = j;
}
}
if (min.p && min.dist < bridgeMax) {
let pj = min.p;
if (debug) console.log({ connect_stubs: [ pi, pj ]});
input.push({ p1: pi, p2: pj });
addConnected(pi, pj);
addConnected(pj, pi);
if (pi.group.length > 2) forks.push(pi);
if (pj.group.length > 2) forks.push(pj);
stubs[i] = undefined;
stubs[min.j] = undefined;
}
}
stubs = stubs.filter(p => p);
if (debug) console.log({ filter_stubs: stubs });
} }
if (debug && (forks || frays)) console.log({forks, frays});
// process paths starting with forks // process paths starting with forks
if (forks) { // find the path back to the starting point
// representing the greatest enclosed area
if (debug) console.log('process forks'); if (debug) console.log('process forks');
for (let point of points) { for (let point of forks) {
// must not have been used and be a dangling end // must not have been used and be a dangling end
if (!point.del && point.group.length > 2) { if (!point.del && point.group.length > 2) {
let path = findNextPath(point); let path = findNextPath(point);
if (path) emitPath(path); if (path) emitPath(path);
} }
} } }
// process paths with dangling endpoints // process remaining paths
if (frays) { // find the path back to the starting point
if (debug) console.log('process frays'); // representing the greatest enclosed area
for (let point of points) { if (debug) console.log('process norms');
// must not have been used and be a dangling end
if (!point.del && point.group.length === 1) {
let path = findNextPath(point);
if (path) emitPath(path);
}
} }
// process normal paths
if (debug) console.log('process mids');
for (let point of points) { for (let point of points) {
// must not have been used and be a dangling end // must not have been used and be a dangling end
if (!point.del) { if (!point.del) {
@ -649,7 +743,7 @@ export function sliceConnect(input, z, opt = {}) {
// progressively connect open polygons within a bridge distance // progressively connect open polygons within a bridge distance
let iter = 1000; let iter = 1000;
let mingap; let mingap;
if (true) do { if (false) do {
mingap = Infinity; mingap = Infinity;
outer: for (let i=0; i<connect.length; i++) { outer: for (let i=0; i<connect.length; i++) {

View file

@ -2,8 +2,18 @@
import { api } from './api.js'; import { api } from './api.js';
/**
* Active alert records. Each record is an array: [message, timestamp, duration, active]
* @type {Array<[string, number, number, boolean]>}
*/
let alerts = []; let alerts = [];
/**
* Display an alert message to the user
* @param {string} message - The message to display
* @param {number} time - Duration in seconds to show the alert
* @returns {Array|undefined} Alert record [message, timestamp, duration, active] or result of update()
*/
function show(message, time) { function show(message, time) {
if (message === undefined || message === null) { if (message === undefined || message === null) {
return update(true); return update(true);
@ -19,6 +29,11 @@ function show(message, time) {
return rec; return rec;
} }
/**
* Hide one or more alerts
* @param {Array|Array<Array>} rec - Single alert record or array of records to hide
* @param {Array<Array>} [recs] - Optional array of alert records (deprecated parameter style)
*/
function hide(rec, recs) { function hide(rec, recs) {
if (Array.isArray(recs)) { if (Array.isArray(recs)) {
for (let r of recs) { for (let r of recs) {
@ -36,6 +51,11 @@ function hide(rec, recs) {
} }
} }
/**
* Update the alert display by filtering expired/inactive alerts and rendering active ones.
* Filters alerts by age (based on duration) and active flag, limits display to 5 alerts.
* @param {boolean} [clear] - If true, clears all alerts before updating
*/
function update(clear) { function update(clear) {
if (clear) { if (clear) {
alerts = []; alerts = [];

294
src/kiri/app/api.js Normal file
View file

@ -0,0 +1,294 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import '../../ext/base64.js';
import alerts from './alerts.js';
import settings from './conf/manager.js';
import STACKS from './stacks.js';
import web from '../../moto/webui.js';
import { beta, version } from '../../moto/license.js';
import { broker } from '../../moto/broker.js';
import { client as workers } from './workers.js';
import { consts, LISTS as lists } from './consts.js';
import { device, devices } from './devices.js';
import { functions } from './function.js';
import { group as groupModule } from './groups.js';
import { help as helpModule } from './help.js';
import { image as imageModule } from './image.js';
import { Index } from '../../data/index.js';
import { LANG } from './language.js';
import { local } from './local.js';
import { local as dataLocal } from '../../data/local.js';
import { modal } from './modal.js';
import { mode as modeModule, process as processModule } from './mode.js';
import { newWidget } from './widget.js';
import { noop, ajax, o2js, js2o, utils } from '../core/utils.js';
import { openFiles } from './files.js';
import { platform } from './platform.js';
import { selection } from './selected.js';
import { settingsUI } from './conf/dialog.js';
import { showDevices } from './devices.js';
import { showTools } from '../mode/cam/app/tools.js';
import { space as SPACE } from '../../moto/space.js';
import { stats } from './stats.js';
import { types as load } from '../../load/file.js';
import { UI } from './inputs.js';
import { updateTool } from '../mode/cam/app/tools.js';
import { view as viewModule } from './view-mode.js';
import { visuals } from './visuals.js';
import { widgets } from './widgets.js';
import { workspace } from './workspace.js';
// environment setup
let LOC = self.location,
EVENT = broker,
SETUP = utils.parseOpt(LOC.search.substring(1)),
FILES = openFiles(new Index(SETUP.d ? SETUP.d[0] : 'kiri')),
LOCAL = self.debug && !SETUP.remote,
SECURE = isSecure(LOC.protocol);
// todo: fix in widget.js b/c front-end and back-end do not share api
self.kiri_catalog = FILES;
FILES.show = () => modal.show('files');
/**
* Broker compatibility patch - adds 'on' method as alias for subscribe
*/
EVENT.on = (topic, listener) => {
EVENT.subscribe(topic, listener);
return EVENT;
};
/**
* Check if protocol is secure (HTTPS).
* @param {string} proto - Protocol string (e.g., "https:", "http:")
* @returns {boolean} True if protocol starts with "https"
*/
function isSecure(proto) {
return proto.toLowerCase().indexOf("https") === 0;
}
/**
* Download data as a file using browser download mechanism.
* Creates temporary object URL and triggers download via hidden link click.
* @param {Blob|ArrayBuffer|string} data - Data to download
* @param {string} filename - Filename for download
*/
function download(data, filename) {
let url = window.URL.createObjectURL(new Blob([data], {type: "octet/stream"}));
$('mod-any').innerHTML = `<a id="_dexport_" href="${url}" download="${filename}">x</a>`;
$('_dexport_').click();
}
/** Busy state counter for tracking active operations */
let busyVal = 0,
/** Hover feature flag */
isHover = false,
/** Explicit undefined for object defaults */
undef = undefined;
/**
* organize modules for easy use in other modules
* without having to know the paths to all of them
*/
export const api = {
ajax,
beta,
alerts,
/**
* Busy state management for tracking active operations.
* Emits 'busy' events when state changes.
*/
busy: {
val() { return busyVal },
inc() { api.event.emit("busy", ++busyVal) },
dec() { api.event.emit("busy", --busyVal) }
},
catalog: FILES,
client: workers,
clip(text) {
navigator.clipboard
.writeText(text)
.catch(err => console.error('Clipboard Error:', err));
},
clone: Object.clone,
conf: settings.conf,
const: {
LANG, LOCAL, SETUP, SECURE, SPACE, STACKS,
...consts,
URL: {
path: LOC.pathname,
hash: LOC.hash.substring(1),
query: LOC.search.substring(1)
}
},
/**
* Development/debug utilities
*/
devel: {
get enabled() {
return settings.ctrl().devel;
},
/**
* Enable X-ray view of specific layers for debugging slicing.
* Converts layer indices to Z-heights and triggers re-slice.
* @param {number|number[]} layers - Layer index or array of indices to view
* @param {boolean} [raw] - If true, use raw values without height calculation
*/
xray(layers, raw) {
let proc = api.conf.get().process,
size = proc.sliceHeight || proc.slaSlice || 1,
base = (proc.firstSliceHeight || size);
layers = Array.isArray(layers) ? layers : [layers];
proc.xray = layers.map(l => raw ? l : base + l * size - size / 2);
proc.xrayi = layers.slice();
api.function.slice(
// () => SPACE.platform.showGrid(false)
);
}
},
device,
devices,
dialog: {
show: (which) => modal.show(which),
hide: () => modal.hide(),
update_process_list: settingsUI.update_list
},
doit: {
undo: noop, // do.js
redo: noop // do.js
},
event: {
alerts(clr) { alerts.update(clr) },
bind(t,m,o) { return EVENT.bind(t,m,o) },
emit(t,m,o) { return EVENT.publish(t,m,o) },
import() { api.ui.load.click() },
listeners(topic) { return EVENT.targets(topic) },
on(t,l) { EVENT.on(t,l); return api.event },
settings: settingsUI.trigger_event,
topics() { return EVENT.topics() }
},
electron: navigator.userAgent.includes('Electron'),
/**
* Feature flags and hook functions for customizing application behavior.
* Many of these are hook points for external integrations or plugins.
*/
feature: {
seed: true, // seed profiles on first use
meta: true, // show selected widget metadata
frame: true, // receive frame events
alert_event: false, // emit alerts as events instead of display
controls: true, // show or not side menus
device_filter: undef, // function to limit devices shown
drop_group: undef, // optional array to group multi drop
drop_layout: true, // layout on new drop
hoverAdds: false, // when true only searches widget additions
on_key: undef, // function override default key handlers
on_key2: [], // allows for multiple key handlers
on_load: undef, // function override file drop loads
on_add_stl: undef, // legacy override stl drop loads
on_mouse_up: undef, // function intercepts mouse up select
on_mouse_down: undef, // function intercepts mouse down
work_alerts: true, // allow disabling work progress alerts
pmode: consts.PMODES.SPEED, // preview modes
/**
* Hover feature flag. Setting this publishes a "feature.hover" event.
*/
get hover() {
return isHover;
},
set hover(b) {
isHover = b;
broker.publish("feature.hover", b);
}
},
function: functions,
group: groupModule,
help: helpModule,
hide: {
alert(rec, recs) { alerts.hide(...arguments) },
import: noop,
slider: visuals.hide_slider
},
image: imageModule,
js2o,
language: LANG,
lists,
load,
local,
modal,
mode: modeModule,
new: {
widget: newWidget
},
noop,
o2js,
onkey(fn) {
api.feature.on_key2.push(fn);
},
platform,
process: processModule,
sdb: dataLocal,
selection,
settings,
show: {
alert() { return alerts.show(...arguments) },
controls() { console.trace('deprecated') },
devices: showDevices,
import() { api.ui.import.style.display = '' },
layer: visuals.set_visible_layer,
local() { console.trace('deprecated') },
progress: visuals.set_progress,
slices: visuals.show_slices,
tools: showTools
},
space: workspace,
stacks: STACKS,
stats,
tool: {
update: updateTool
},
tweak: {
line_precision(v) { api.work.config({ base: { clipperClean: v } }) },
gcode_decimals(v) { api.work.config({ base: { gcode_decimals: v } }) }
},
uc: UI.prefix('kiri').inputAction(settings.conf.update),
ui: {},
util: {
isSecure,
download,
ui2rec() { api.conf.update_from(...arguments) },
rec2ui() { api.conf.update_fields(...arguments) },
/**
* Encode object to base64 string via JSON serialization
* @param {*} obj - Object to encode
* @returns {string} Base64 encoded string
*/
b64enc(obj) { return base64js.fromByteArray(new TextEncoder().encode(JSON.stringify(obj))) },
/**
* Decode base64 string to object via JSON parsing
* @param {string} obj - Base64 encoded string
* @returns {*} Decoded object
*/
b64dec(obj) { return JSON.parse(new TextDecoder().decode(base64js.toByteArray(obj))) }
},
// var: {
// layer_lo: 0,
// layer_hi: 0,
// layer_max: 0
// },
version,
view: {
...viewModule,
...visuals,
snapshot: null
},
visuals,
web,
widgets,
work: workers,
};
// allow widget to straddle client / worker FOR NOW
self.kiri_api = api;

View file

@ -0,0 +1,69 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
/**
* BASE_SCHEME: Shared colors used across all device modes
* All mode-specific schemes inherit from this base
*/
const BASE_SCHEME = {
// Widget colors for selected/deselected states
widget: {
selected: {
light: [ 0xbbff00, 0xbbee00, 0xbbdd00, 0xbb9900 ],
dark: [ 0xbbff00, 0xbbee00, 0xbbdd00, 0xbb9900 ]
},
deselected: {
light: [ 0xffff00, 0xffdd00, 0xffbb00, 0xff9900 ],
dark: [ 0xffff00, 0xffdd00, 0xffbb00, 0xff9900 ]
},
disabled: {
// Computed via avgc(0x888888, baseColor, 3)
mixWith: 0x888888,
mixRatio: 3
}
},
// Edge rendering settings
edges: {
color: {
light: 0x888888,
dark: 0x444444
},
angle: 20 // Default edge detection angle threshold
},
// Wireframe rendering settings
wireframe: {
color: {
light: 0x000000,
dark: 0xaaaaaa
},
opacity: {
light: 0.5,
dark: 0.25
}
},
// Grid colors for platform
grid: {
major: {
light: 0x999999,
dark: 0x666666
},
minor: {
light: 0xcccccc,
dark: 0x333333
}
},
// View-specific opacity defaults (common across modes)
views: {
ARRANGE: {
model_opacity: 1.0
},
SLICE: {
slicing_opacity: 0.5
}
}
};
export { BASE_SCHEME };

58
src/kiri/app/color/cam.js Normal file
View file

@ -0,0 +1,58 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { BASE_SCHEME } from './base.js';
/**
* CAM_SCHEME: Colors specific to CAM (3-axis milling/machining) mode
* Inherits from BASE_SCHEME and overrides/extends with CAM-specific values
*/
const CAM_SCHEME = {
// Inherit all base colors
...BASE_SCHEME,
// CAM-specific view overrides
views: {
...BASE_SCHEME.views,
SLICE: {
...BASE_SCHEME.views.SLICE,
sliced_opacity: 0.2 // CAM shows more during slice (vs FDM 0.0)
},
PREVIEW: {
preview: {
light: 0xdddddd,
dark: 0x888888
},
preview_opacity: {
light: 0.2,
dark: 0.2
}
}
},
// CAM-specific operations
operations: {
// Tab visualization (persistent geometry)
tabs: {
color: {
light: 0x0000dd, // Blue in light mode
dark: 0x00ddff // Cyan in dark mode
},
opacity: {
light: 0.6,
dark: 0.75
}
}
},
// Gcode preview colors (same as FDM, but documented for CAM)
gcode: {
head: 0x888888,
move: {
light: 0xaaaaaa,
dark: 0x666666
},
print: 0x777700
}
};
export { CAM_SCHEME };

42
src/kiri/app/color/fdm.js Normal file
View file

@ -0,0 +1,42 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { BASE_SCHEME } from './base.js';
/**
* FDM_SCHEME: Colors specific to FDM (Fused Deposition Modeling) mode
* Inherits from BASE_SCHEME and overrides/extends with FDM-specific values
*/
const FDM_SCHEME = {
// Inherit all base colors
...BASE_SCHEME,
// FDM-specific view overrides
views: {
...BASE_SCHEME.views,
PREVIEW: {
preview_opacity: 0.0, // FDM hides widget in preview
sliced_opacity: 0.0
}
},
// FDM-specific operations
operations: {
// Support painting overlay
paint: {
overlay: 0x4488ff, // Light blue for painted support regions
opacity: 0.7
}
},
// Gcode preview colors
gcode: {
head: 0x888888,
move: {
light: 0xaaaaaa,
dark: 0x666666
},
print: 0x777700
}
};
export { FDM_SCHEME };

View file

@ -0,0 +1,28 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { BASE_SCHEME } from './base.js';
/**
* LASER_SCHEME: Colors for LASER, DRAG (knife), WJET (waterjet), and WEDM (wire-edm) modes
* All 2D device types share similar visual characteristics
* Inherits from BASE_SCHEME with minimal overrides
*/
const LASER_SCHEME = {
// Inherit all base colors
...BASE_SCHEME,
// Laser-specific view overrides (similar to CAM)
views: {
...BASE_SCHEME.views,
PREVIEW: {
preview_opacity: 0.2
}
}
};
// DRAG, WJET, and WEDM all use the same scheme as LASER
const DRAG_SCHEME = LASER_SCHEME;
const WJET_SCHEME = LASER_SCHEME;
const WEDM_SCHEME = LASER_SCHEME;
export { LASER_SCHEME, DRAG_SCHEME, WJET_SCHEME, WEDM_SCHEME };

View file

@ -0,0 +1,145 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { FDM_SCHEME } from './fdm.js';
import { CAM_SCHEME } from './cam.js';
import { SLA_SCHEME } from './sla.js';
import { LASER_SCHEME, DRAG_SCHEME, WJET_SCHEME, WEDM_SCHEME } from './laser.js';
/**
* ColorSchemeRegistry: Central registry for all mode-specific color schemes
* Provides theme-aware color resolution for UI rendering
*/
class ColorSchemeRegistry {
constructor() {
// Map mode IDs to their schemes
this.schemes = {
1: FDM_SCHEME, // FDM mode
2: LASER_SCHEME, // LASER mode
3: CAM_SCHEME, // CAM mode
4: SLA_SCHEME, // SLA mode
5: DRAG_SCHEME, // DRAG knife mode
6: WJET_SCHEME, // Waterjet mode
7: WEDM_SCHEME // Wire-EDM mode
};
}
/**
* Get the complete scheme for a given mode and theme
* @param {number|string} mode - Mode ID (1=FDM, 2=LASER, 3=CAM, etc) or name
* @param {string} theme - 'light' or 'dark'
* @returns {object} Resolved scheme object
*/
getScheme(mode, theme = 'light') {
// Convert mode name to ID if needed
if (typeof mode === 'string') {
mode = this._modeNameToId(mode);
}
const scheme = this.schemes[mode];
if (!scheme) {
console.warn(`Unknown mode: ${mode}, using FDM scheme as fallback`);
return this._resolveTheme(this.schemes[1], theme);
}
return this._resolveTheme(scheme, theme);
}
/**
* Get a specific color value from a scheme
* @param {number|string} mode - Mode ID or name
* @param {string} theme - 'light' or 'dark'
* @param {string} path - Dot-separated path (e.g., 'widget.selected', 'edges.color')
* @returns {*} Color value or object
*/
getColor(mode, theme, path) {
const scheme = this.getScheme(mode, theme);
return this._getNestedValue(scheme, path);
}
/**
* Compute disabled color by mixing base color with gray
* Implements: avgc(0x888888, baseColor, 3)
* @param {number} baseColor - Base color hex value
* @returns {number} Mixed color hex value
*/
computeDisabledColor(baseColor) {
// Extract RGB components
const r1 = (0x888888 >> 16) & 0xFF;
const g1 = (0x888888 >> 8) & 0xFF;
const b1 = 0x888888 & 0xFF;
const r2 = (baseColor >> 16) & 0xFF;
const g2 = (baseColor >> 8) & 0xFF;
const b2 = baseColor & 0xFF;
// Average with ratio 3:1 (3 parts gray, 1 part base)
const r = Math.floor((r1 * 3 + r2) / 4);
const g = Math.floor((g1 * 3 + g2) / 4);
const b = Math.floor((b1 * 3 + b2) / 4);
return (r << 16) | (g << 8) | b;
}
/**
* Resolve theme-specific values throughout the scheme
* @private
*/
_resolveTheme(scheme, theme) {
const resolved = JSON.parse(JSON.stringify(scheme)); // Deep clone
this._resolveThemeRecursive(resolved, theme);
return resolved;
}
/**
* Recursively resolve theme values in an object
* @private
*/
_resolveThemeRecursive(obj, theme) {
for (const key in obj) {
const value = obj[key];
if (value && typeof value === 'object') {
// Check if this object has light/dark properties
if ('light' in value && 'dark' in value) {
// Replace the object with the theme-specific value
obj[key] = value[theme];
} else {
// Recurse into nested objects
this._resolveThemeRecursive(value, theme);
}
}
}
}
/**
* Get nested value from object using dot-separated path
* @private
*/
_getNestedValue(obj, path) {
return path.split('.').reduce((current, key) => current?.[key], obj);
}
/**
* Convert mode name to ID
* @private
*/
_modeNameToId(name) {
const modes = {
'FDM': 1,
'LASER': 2,
'CAM': 3,
'SLA': 4,
'DRAG': 5,
'WJET': 6,
'WEDM': 7
};
return modes[name.toUpperCase()] || 1; // Default to FDM
}
}
// Create singleton instance
const colorSchemeRegistry = new ColorSchemeRegistry();
export { colorSchemeRegistry, ColorSchemeRegistry };

23
src/kiri/app/color/sla.js Normal file
View file

@ -0,0 +1,23 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { BASE_SCHEME } from './base.js';
/**
* SLA_SCHEME: Colors specific to SLA (stereolithography/resin) mode
* Inherits from BASE_SCHEME with minimal overrides
*/
const SLA_SCHEME = {
// Inherit all base colors
...BASE_SCHEME,
// SLA-specific view overrides (similar to FDM)
views: {
...BASE_SCHEME.views,
PREVIEW: {
preview_opacity: 0.0,
sliced_opacity: 0.0
}
}
};
export { SLA_SCHEME };

View file

@ -261,7 +261,9 @@ export const conf = {
// process defaults FDM:Process // process defaults FDM:Process
p:{ p:{
antiBacklash: 0, antiBacklash: 0,
arcTolerance: 0, fdmArcEnabled: false,
fdmArcResolution: 1,
fdmArcTolerance: 0.005,
firstLayerBedTemp: 0, firstLayerBedTemp: 0,
firstLayerBeltBump: 0, firstLayerBeltBump: 0,
firstLayerBeltLead: 3, firstLayerBeltLead: 3,
@ -335,18 +337,17 @@ export const conf = {
sliceShells: 3, sliceShells: 3,
sliceSolidify: false, sliceSolidify: false,
sliceSolidMinArea: 1, sliceSolidMinArea: 1,
sliceSolidMinThick: 0.4,
sliceSupportAngle: 50, sliceSupportAngle: 50,
sliceSupportArea: 0.1,
sliceSupportDensity: 0.1, sliceSupportDensity: 0.1,
sliceSupportEnable: false,
sliceSupportExtra: 0, sliceSupportExtra: 0,
sliceSupportGap: 1, sliceSupportGap: 1,
sliceSupportGrow: 0,
sliceSupportNozzle: 0, sliceSupportNozzle: 0,
sliceSupportOffset: 1.0, sliceSupportOffset: 1.0,
sliceSupportOutline: true, sliceSupportOutline: true,
sliceSupportSize: 5,
sliceSupportSpan: 5, sliceSupportSpan: 5,
sliceSupportType: "disabled",
sliceSupportTree: false,
sliceTopLayers: 3, sliceTopLayers: 3,
sliceZInterleave: false, sliceZInterleave: false,
zHopDistance: 0.2, zHopDistance: 0.2,
@ -443,9 +444,12 @@ export const conf = {
camAreaExpand: 0, camAreaExpand: 0,
camAreaRefine: 0, camAreaRefine: 0,
camAreaSmooth: 1, camAreaSmooth: 1,
camAreaShadow: false,
camAreaDogbones: false, camAreaDogbones: false,
camAreaRevbones: false, camAreaRevbones: false,
camAreaOutline: false, camAreaOutline: false,
camAreaWalls: false,
camAreaZigZag: true,
camContourAngle: 85, camContourAngle: 85,
camContourBottom: false, camContourBottom: false,
camContourBridge: 0, camContourBridge: 0,
@ -517,6 +521,8 @@ export const conf = {
camLatheAngle: 1, camLatheAngle: 1,
camLatheLinear: true, camLatheLinear: true,
camLatheOver: 0.1, camLatheOver: 0.1,
camLatheDown: 0,
camLatheExpand: 0,
camLatheSpeed: 500, camLatheSpeed: 500,
camLatheSpindle: 1000, camLatheSpindle: 1000,
camLatheTool: 1000, camLatheTool: 1000,
@ -601,6 +607,7 @@ export const conf = {
camTraceLines: false, camTraceLines: false,
camTraceMerge: false, camTraceMerge: false,
camTraceOffOver: 0, camTraceOffOver: 0,
camTraceOffset: "none",
camTraceOver: 0.5, camTraceOver: 0.5,
camTracePlunge: 200, camTracePlunge: 200,
camTraceSpeed: 250, camTraceSpeed: 250,
@ -614,7 +621,6 @@ export const conf = {
camZBottom: 0, camZBottom: 0,
camZClearance: 1, camZClearance: 1,
camZOffset: 0, camZOffset: 0,
// camZThru: 0,
camZTop: 0, camZTop: 0,
op2: [], // flip ops op2: [], // flip ops
ops: [], // current ops ops: [], // current ops
@ -998,6 +1004,7 @@ export const conf = {
widget: {}, widget: {},
// legacy localStorage settings (like octo print) // legacy localStorage settings (like octo print)
local: { local: {
'model.opacity': 1.0,
'model.edges': false, 'model.edges': false,
'cam.anim.trans': true, 'cam.anim.trans': true,
'cam.anim.model': false, 'cam.anim.model': false,

123
src/kiri/app/conf/dialog.js Normal file
View file

@ -0,0 +1,123 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { api } from '../api.js';
import { modal } from '../modal.js';
import { settings } from './manager.js';
import { version } from '../../../moto/license.js';
const DOC = self.document;
function triggerSettingsEvent() {
api.event.emit('settings', settings.get());
}
function editSettings(e) {
let current = settings.get(),
mode = settings.mode(),
name = e.target.getAttribute("name"),
load = current.sproc[mode][name],
loadstr = JSON.stringify(load,null,4).split('\n');
api.uc.prompt(`settings for "${name}"`, loadstr).then(edit => {
if (edit) {
try {
current.sproc[mode][name] = JSON.parse(edit);
if (name === settings.proc().processName) {
api.conf.load(null, name);
}
api.conf.save();
api.settings.sync.put();
} catch (e) {
console.log({ malformed_settings: e });
api.uc.alert('malformed settings object');
}
}
});
}
function exportSettings(e) {
let current = settings.get(),
mode = settings.mode(),
name = e.target.getAttribute("name"),
data = api.util.b64enc({
process: current.sproc[mode][name],
version,
moto: moto.id,
time: Date.now(),
mode,
name
});
api.uc.prompt("Export Process Filename", name).then(name => {
if (name) {
api.util.download(data, `${name}.km`);
}
});
}
function deleteSettings(e) {
let current = settings.get();
let name = e.target.getAttribute("del");
delete current.sproc[settings.mode()][name];
api.settings.sync.put();
updateProcessList();
api.conf.save();
triggerSettingsEvent();
}
function updateProcessList() {
let current = settings.get();
let list = [], s = current, sp = s.sproc[settings.mode()] || {}, table = api.ui.settingsList;
table.innerHTML = '';
for (let k in sp) {
if (sp.hasOwnProperty(k)) list.push(k);
}
list.filter(n => n !=='default').sort().forEach(function(sk) {
let row = DOC.createElement('div'),
load = DOC.createElement('button'),
edit = DOC.createElement('button'),
xprt = DOC.createElement('button'),
del = DOC.createElement('button'),
name = sk;
load.setAttribute('load', sk);
load.onclick = (ev) => {
api.conf.load(undefined, sk);
updateProcessList();
modal.hide();
}
load.appendChild(DOC.createTextNode(sk));
if (sk == settings.proc().processName) {
load.setAttribute('class', 'selected')
}
api.ui.settingsName.value = settings.proc().processName;
del.setAttribute('del', sk);
del.setAttribute('title', "remove '"+sk+"'");
del.innerHTML = '<i class="far fa-trash-alt"></i>';
del.onclick = deleteSettings;
edit.setAttribute('name', sk);
edit.setAttribute('title', 'edit');
edit.innerHTML = '<i class="far fa-edit"></i>';
edit.onclick = editSettings;
xprt.setAttribute('name', sk);
xprt.setAttribute('title', 'export');
xprt.innerHTML = '<i class="fas fa-download"></i>';
xprt.onclick = exportSettings;
row.setAttribute("class", "flow-row");
row.appendChild(edit);
row.appendChild(load);
row.appendChild(xprt);
row.appendChild(del);
table.appendChild(row);
});
}
export const settingsUI = {
edit: editSettings,
export: exportSettings,
delete: deleteSettings,
update_list: updateProcessList,
trigger_event: triggerSettingsEvent
};

View file

@ -1,20 +1,31 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */ /** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { $, h } from '../../moto/webui.js'; import { $, h } from '../../../moto/webui.js';
import { api } from './api.js'; import { api } from '../api.js';
import { conf } from './conf.js'; import { conf } from './defaults.js';
import { codec } from './codec.js'; import { codec } from '../../core/codec.js';
import { consts } from './consts.js'; import { consts } from '../consts.js';
import { space } from '../../moto/space.js'; import { space } from '../../../moto/space.js';
import { local } from '../../data/local.js'; import { local } from '../../../data/local.js';
import { utils } from './utils.js'; import { newWidget } from '../widget.js';
import { version } from '../../moto/license.js'; import { utils } from '../../core/utils.js';
import { JSZip } from '../../ext/jszip-esm.js'; import { version } from '../../../moto/license.js';
import { JSZip } from '../../../ext/jszip-esm.js';
import { colorSchemeRegistry } from '../color/schemes.js';
const { areEqual, ls2o, js2o } = utils; const { areEqual, ls2o, js2o } = utils;
const { COLOR } = consts;
const { clone } = Object; const { clone } = Object;
/**
* Get current color scheme
*/
function getColorScheme() {
const mode = api.mode.get_id();
const theme = api.space.is_dark() ? 'dark' : 'light';
return colorSchemeRegistry.getScheme(mode, theme);
}
const localFilterKey ='kiri-gcode-filters'; const localFilterKey ='kiri-gcode-filters';
const localFilters = js2o(local.getItem(localFilterKey)) || []; const localFilters = js2o(local.getItem(localFilterKey)) || [];
@ -115,7 +126,7 @@ function updateSettings(opt = {}) {
if (settings.mode === 'FDM' && api.view.is_slice()) { if (settings.mode === 'FDM' && api.view.is_slice()) {
let changes = {}; let changes = {};
let values = process; let values = process;
let { layer_lo, layer_hi, layer_max } = api.var; const { lo: layer_lo, hi: layer_hi, max: layer_max } = api.slider.getRange();
let range = { lo: layer_lo, hi: layer_hi }; let range = { lo: layer_lo, hi: layer_hi };
let add = false; let add = false;
if (layer_lo > 0 || layer_hi < layer_max) { if (layer_lo > 0 || layer_hi < layer_max) {
@ -321,9 +332,10 @@ function updateExtruderFields(device) {
onclick() { onclick() {
api.selection.for_widgets(w => { api.selection.for_widgets(w => {
w.anno.extruder = i; w.anno.extruder = i;
const scheme = getColorScheme();
w.setColor( w.setColor(
api.selection.contains(w) ? api.selection.contains(w) ?
COLOR.selected : COLOR.deselected, scheme.widget.selected : scheme.widget.deselected,
undefined, undefined,
false); false);
}); });
@ -331,7 +343,7 @@ function updateExtruderFields(device) {
}), }),
h.div({ h.div({
class: "splat", class: "splat",
style: `background-color: #${COLOR.deselected[i].toString(16)}` style: `background-color: #${getColorScheme().widget.deselected[i].toString(16)}`
}) })
]))); ])));
} else { } else {
@ -450,8 +462,8 @@ function updateFieldsFromRange() {
let match = 0; let match = 0;
let values = {}; let values = {};
let restores = Object.clone(overrides); let restores = Object.clone(overrides);
let { layer_lo, layer_hi } = api.var; const { lo: layer_lo, hi: layer_hi } = api.slider.getRange();
for (let range of getOverlappingRanges(api.var.layer_lo, api.var.layer_hi)) { for (let range of getOverlappingRanges(layer_lo, layer_hi)) {
for (let key of Object.keys(range.fields)) { for (let key of Object.keys(range.fields)) {
values[key] = range.fields[key]; values[key] = range.fields[key];
overrides[key] = settings.process[key]; overrides[key] = settings.process[key];
@ -608,6 +620,7 @@ function settingsImport(data, ask) {
work.type = 100; work.type = 100;
} }
} }
codec.setFactory(codec.TYPE.WIDGET, newWidget);
codec.decode(data.work).forEach(widget => { codec.decode(data.work).forEach(widget => {
api.platform.add(widget, 0, true, true); api.platform.add(widget, 0, true, true);
}); });
@ -756,11 +769,11 @@ function settingsPrusaConvert(data) {
}); });
} }
function setThreaded(bool) { // function setThreaded(bool) {
bool = bool ? true : false; // bool = bool ? true : false;
settings.controller.threaded = bool; // settings.controller.threaded = bool;
api.event.emit("set.threaded", bool); // api.event.emit("set.threaded", bool);
} // }
function setEnableWASM(bool) { function setEnableWASM(bool) {
bool = bool ? true : false; bool = bool ? true : false;
@ -786,7 +799,8 @@ const apiSet = {
update_fields: updateFieldsFromSettings, update_fields: updateFieldsFromSettings,
update_fields_from_range: updateFieldsFromRange, update_fields_from_range: updateFieldsFromRange,
set_enable_wasm: setEnableWASM, set_enable_wasm: setEnableWASM,
set_threaded: setThreaded, // set_threaded: setThreaded,
set_threaded() { console.trace('set_threaded deprecated' )},
restore: restoreSettings, restore: restoreSettings,
export: settingsExport, export: settingsExport,
import: settingsImport, import: settingsImport,

View file

@ -0,0 +1,129 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { $ } from '../../../moto/webui.js';
import { api } from '../api.js';
import { settings as set_ctrl } from './manager.js';
function export_device(exp, name) {
name = (name || "device")
.toLowerCase()
.replace(/ /g,'_')
.replace(/\./g,'_');
api.uc.prompt("Export Device Filename", name).then(name => {
if (name) {
api.util.download(exp, `${name}.km`);
}
});
}
function export_objects(format = "stl") {
// return selection.export();
api.uc.confirm("Export Filename", {ok:true, cancel: false}, `selected.${format}`).then(name => {
if (!name) return;
if (name.toLowerCase().indexOf(`.${format}`) < 0) {
name = `${name}.${format}`;
}
api.util.download(api.selection.export(format), name);
});
}
function export_profile() {
const opt = {pre: [
"<div class='f-col a-center gap5 mlr10'>",
" <h3>Workspace Export</h3>",
" <label>This will create a backup of your</label>",
" <label>workspace, devices, and settings</label>",
" <span class='mt10'><input id='excwork' type='checkbox'>&nbsp;Exclude meshes</span>",
"</div>"
]};
let suggestion = "workspace";
let file = api.widgets.all()[0]?.meta.file || '';
if (file) {
suggestion = `${suggestion}_${file.split('.')[0]}`.replaceAll(' ','_');
};
api.uc.confirm("Filename", {ok:true, cancel: false}, suggestion, opt).then(name => {
if (!name) return;
let work = !$('excwork').checked;
let json = api.conf.export({work, clear:true});
api.client.zip([
{name:"workspace.json", data:JSON.stringify(json)}
], progress => {
api.show.progress(progress.percent/100, "compressing workspace");
}, output => {
api.show.progress(0);
api.util.download(output, `${name}.kmz`);
});
});
}
function new_workspace() {
api.uc.confirm("Clear Workspace?", {ok:true, cancel: false}).then(value => {
if (value === true) {
let proc = set_ctrl.proc();
proc.ops && (proc.ops.length = 0);
proc.op2 && (proc.op2.length = 0);
api.platform.clear();
}
});
}
function settings_save(ev, name) {
if (ev) {
ev.stopPropagation();
ev.preventDefault();
}
api.dialog.hide();
let mode = api.mode.get(),
s = api.conf.get(),
def = "default",
cp = s.process,
pl = s.sproc[mode],
lp = s.cproc[mode],
saveAs = (name) => {
if (!name) {
return;
}
let np = pl[name] = {};
cp.processName = name;
pl[name] = Object.clone(cp);
for (let k in cp) {
if (!cp.hasOwnProperty(k)) continue;
np[k] = cp[k];
}
s.cproc[mode] = name;
s.devproc[s.device.deviceName] = name;
api.conf.save();
api.conf.update();
api.event.settings();
api.settings.sync.put();
};
if (name) {
saveAs(name);
} else {
api.uc.prompt("Save Settings As", cp ? lp || def : def).then(saveAs);
}
}
function settings_load() {
api.conf.show();
}
function update_platform_size() {
api.conf.update();
api.platform.update_size();
api.platform.update_origin();
}
export const settingsOps = {
export_device,
export_objects,
export_profile,
new_workspace,
settings_save,
settings_load,
update_device: update_platform_size,
};

View file

@ -1,21 +1,6 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */ /** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
const COLOR = { // COLOR table has been migrated to src/kiri/app/color/ scheme system
wireframe: 0x444444,
wireframe_opacity: 0.25,
selected: [ 0xbbff00, 0xbbee00, 0xbbdd00, 0xbb9900 ],
deselected: [ 0xffff00, 0xffdd00, 0xffbb00, 0xff9900 ],
model_opacity: 1.0,
preview_opacity: 0.0,
slicing: 0xffaaaa,
slicing_opacity: 0.5,
sliced_opacity: 0.0,
cam_sliced_opacity: 0.2,
cam_preview: 0xdddddd,
cam_preview_opacity: 0.2,
cam_preview_dark: 0x888888,
cam_preview_opacity_dark: 0.2,
};
const LISTS = { const LISTS = {
shell: [ shell: [
@ -23,6 +8,11 @@ const LISTS = {
{ name: "out-in" }, { name: "out-in" },
{ name: "alternate" } { name: "alternate" }
], ],
support: [
{ name: "disabled" },
{ name: "automatic" },
{ name: "manual" },
],
start: [ start: [
{ name: "last" }, { name: "last" },
{ name: "center" }, { name: "center" },
@ -171,22 +161,17 @@ const PMODES = {
TOOLS: 2 TOOLS: 2
}; };
const PATHS = { const LAST = 'kiri-last';
work: "../lib/kiri/run/worker.js",
pool: "./minion.js"
};
const SEED = 'kiri-seed'; const SEED = 'kiri-seed';
export let consts = { export let consts = {
COLOR, LAST,
LISTS, LISTS,
MODES, MODES,
PATHS,
PMODES, PMODES,
VIEWS, VIEWS,
SEED, SEED,
beltfact: Math.cos(Math.PI / 4) beltfact: Math.cos(Math.PI / 4)
}; };
export { COLOR, LISTS, MODES, VIEWS, PATHS, PMODES, SEED }; export { LAST, LISTS, MODES, VIEWS, PMODES, SEED };

View file

@ -2,10 +2,10 @@
import { $, h } from '../../moto/webui.js'; import { $, h } from '../../moto/webui.js';
import { api } from './api.js'; import { api } from './api.js';
import { conf } from './conf.js'; import { conf } from './conf/defaults.js';
import { space } from '../../moto/space.js'; import { space } from '../../moto/space.js';
import { devices as devlist } from '../../pack/kiri-devs.js'; import { devices as devlist } from '../../pack/kiri-devs.js';
import { settings, conf as setconf } from './settings.js'; import { settings, conf as setconf } from './conf/manager.js';
export const device = { export const device = {
clone: cloneDevice, clone: cloneDevice,
@ -92,7 +92,11 @@ function selectDevice(devicename) {
} }
} }
// only for local filters /**
* Clone the current device to create a customizable local copy.
* Naming logic: if device already has "My" prefix, appends " copy", otherwise adds "My" prefix.
* Only works for local devices (not stock devices).
*/
function cloneDevice() { function cloneDevice() {
let name = `${getSelectedDevice().replace(/\./g,' ')}`; let name = `${getSelectedDevice().replace(/\./g,' ')}`;
let code = api.clone(setconf.get().device); let code = api.clone(setconf.get().device);
@ -113,6 +117,18 @@ function updateLaserState() {
$('laser-off').style.display = dev.useLaser ? 'flex' : 'none'; $('laser-off').style.display = dev.useLaser ? 'flex' : 'none';
} }
/**
* Set device configuration from code and initialize device state.
* Complex initialization that:
* - Parses device code (string or object)
* - Fills missing device fields with defaults
* - Handles first-time device setup with profiles
* - Updates UI elements and platform configuration
* - Manages device/process associations
* - Emits device.select and device.selected events
* @param {string|object} code - Device configuration code or object
* @param {string} devicename - Name of the device being set
*/
function setDeviceCode(code, devicename) { function setDeviceCode(code, devicename) {
api.event.emit('device.select', devicename); api.event.emit('device.select', devicename);
try { try {
@ -213,24 +229,24 @@ function setDeviceCode(code, devicename) {
e.disabled = !local; e.disabled = !local;
}); });
ui.deviceSave.disabled = !local; ui.devices.save.disabled = !local;
ui.deviceDelete.disabled = !local; ui.devices.delete.disabled = !local;
ui.deviceRename.disabled = !local; ui.devices.rename.disabled = !local;
ui.deviceExport.disabled = !local; ui.devices.export.disabled = !local;
ui.deviceAdd.style.display = mode === 'SLA' ? 'none' : ''; ui.devices.add.style.display = mode === 'SLA' ? 'none' : '';
if (local) { if (local) {
ui.deviceAdd.innerText = "copy"; ui.devices.add.innerText = "copy";
ui.deviceDelete.style.display = ''; ui.devices.delete.style.display = '';
ui.deviceRename.style.display = ''; ui.devices.rename.style.display = '';
ui.deviceExport.style.display = ''; ui.devices.export.style.display = '';
} else { } else {
ui.deviceAdd.innerText = "customize"; ui.devices.add.innerText = "customize";
ui.deviceDelete.style.display = 'none'; ui.devices.delete.style.display = 'none';
ui.deviceRename.style.display = 'none'; ui.devices.rename.style.display = 'none';
ui.deviceExport.style.display = 'none'; ui.devices.export.style.display = 'none';
} }
ui.deviceAdd.disabled = dev.noclone; ui.devices.add.disabled = dev.noclone;
setconf.update_fields(); setconf.update_fields();
space.platform.setBelt(isBelt()); space.platform.setBelt(isBelt());
@ -277,6 +293,12 @@ function setDeviceCode(code, devicename) {
api.event.settings(); api.event.settings();
} }
/**
* Render device selection UI with both stock and custom devices.
* Builds device list dropdown, sets up event handlers for save/add/delete/rename/export,
* and separates local "My Devices" from "Stock Devices" in the UI.
* @param {string[]} devices - Array of stock device names for current mode
*/
function renderDevices(devices) { function renderDevices(devices) {
let selected = api.device.get() || devices[0], let selected = api.device.get() || devices[0],
features = api.feature, features = api.feature,
@ -301,7 +323,7 @@ function renderDevices(devices) {
event.emit('devices.render', devices); event.emit('devices.render', devices);
ui.deviceSave.onclick = function() { ui.devices.save.onclick = function() {
event.emit('device.save'); event.emit('device.save');
api.function.clear(); api.function.clear();
setconf.save(); setconf.save();
@ -309,18 +331,18 @@ function renderDevices(devices) {
showDevices(); showDevices();
api.modal.hide(); api.modal.hide();
}; };
ui.deviceAdd.onclick = function() { ui.devices.add.onclick = function() {
api.function.clear(); api.function.clear();
cloneDevice(); cloneDevice();
showDevices(); showDevices();
}; };
ui.deviceDelete.onclick = function() { ui.devices.delete.onclick = function() {
api.function.clear(); api.function.clear();
removeLocalDevice(getSelectedDevice()); removeLocalDevice(getSelectedDevice());
selectDevice(getModeDevices()[0]); selectDevice(getModeDevices()[0]);
showDevices(); showDevices();
}; };
ui.deviceRename.onclick = function() { ui.devices.rename.onclick = function() {
api.uc.prompt(`Rename "${selected}`, selected).then(newname => { api.uc.prompt(`Rename "${selected}`, selected).then(newname => {
if (newname) { if (newname) {
updateDeviceName(newname); updateDeviceName(newname);
@ -332,7 +354,7 @@ function renderDevices(devices) {
} }
}); });
}; };
ui.deviceExport.onclick = function(event) { ui.devices.export.onclick = function(event) {
const record = { const record = {
version: api.version, version: api.version,
device: selected, device: selected,

View file

@ -5,12 +5,22 @@ import { space } from '../../moto/space.js';
const { event } = api; const { event } = api;
/**
* Undo/Redo action stack. Each entry contains {undo, redo} action records.
* @type {Array<{undo: object, redo: object}>}
*/
let stack = []; let stack = [];
/** Current position in the undo/redo stack */
let stpos = 0; let stpos = 0;
/** Accumulator for drag movements - tracks cumulative x,y offset during selection drag */
let moved = { x: 0, y: 0 }; let moved = { x: 0, y: 0 };
/** Message ID for the current undo/redo alert message */
let msgid; let msgid;
event.on("init-done", () => { event.on("load-done", () => {
$('undo').onclick = undo; $('undo').onclick = undo;
$('redo').onclick = redo; $('redo').onclick = redo;
}); });
@ -31,6 +41,9 @@ let redo = api.doit.redo = function() {
} }
}; };
/**
* Clear the entire undo/redo stack and reset moved accumulator
*/
let clear = api.doit.clear = function() { let clear = api.doit.clear = function() {
stack = []; stack = [];
stpos = 0; stpos = 0;
@ -52,6 +65,13 @@ function message(txt) {
msgid = api.show.alert(txt); msgid = api.show.alert(txt);
} }
/**
* Execute an action record (undo or redo).
* Dispatches based on action type: move, rotate, or scale.
* @param {object} rec - Action record with type and action-specific properties
* @param {string} rec.type - Action type: 'move', 'rotate', or 'scale'
* @param {Array} rec.widgets - Widgets to operate on
*/
function action(rec) { function action(rec) {
switch (rec.type) { switch (rec.type) {
case 'move': case 'move':
@ -75,6 +95,11 @@ function action(rec) {
updateButtons(); updateButtons();
} }
/**
* Push an undo/redo action pair onto the stack.
* Truncates stack at current position (clearing any "future" when adding new action).
* @param {object} ur - Action pair with {undo, redo} records
*/
function pushActions(ur) { function pushActions(ur) {
stack.length = stpos++; stack.length = stpos++;
stack.push(ur); stack.push(ur);

View file

@ -2,14 +2,18 @@
import { $ } from '../../moto/webui.js'; import { $ } from '../../moto/webui.js';
import { api } from './api.js'; import { api } from './api.js';
import { client } from './client.js'; import { client } from './workers.js';
import { local } from '../../data/local.js'; import { local } from '../../data/local.js';
import { util } from '../../geo/base.js'; import { util } from '../../geo/base.js';
import { MODES } from './consts.js'; import { MODES } from './consts.js';
import { LASER as laser_driver } from '../mode/laser/driver.js'; import { LASER as laser_driver } from '../mode/laser/init-work.js';
import { SLA as sla_client } from '../mode/sla/client.js'; import { SLA as sla_client } from '../mode/sla/app/init-ui.js';
import { hash } from '../../ext/md5.js'; import { hash } from '../../ext/md5.js';
/**
* Sequential print counter for generating unique export filenames.
* Increments with each export and persists in local storage.
*/
let printSeq = parseInt(local['kiri-print-seq'] || local['print-seq'] || "0") + 1; let printSeq = parseInt(local['kiri-print-seq'] || local['print-seq'] || "0") + 1;
function localGet(key) { function localGet(key) {
@ -20,6 +24,12 @@ function localSet(key, val) {
return api.local.set(key, val); return api.local.set(key, val);
} }
/**
* Main export entry point. Dispatches to mode-specific export handlers.
* Extracts widget filenames to use for export filename suggestions.
* @param {object} options - Export options (mode-specific)
* @returns {*} Result from mode-specific export handler
*/
export function exportFile(options) { export function exportFile(options) {
let mode = api.mode.get(); let mode = api.mode.get();
let names = api.widgets.all().map(w => w.meta ? w.meta.file : undefined) let names = api.widgets.all().map(w => w.meta ? w.meta.file : undefined)
@ -39,6 +49,13 @@ export function exportFile(options) {
} }
} }
/**
* Export gcode for FDM or CAM modes.
* Calls worker to generate gcode, then presents dialog or invokes callback.
* @param {function} callback - Optional callback(gcode_string, output_info)
* @param {string} mode - Current mode ('FDM' or 'CAM')
* @param {string[]} names - Widget filenames for export naming
*/
function callExport(callback, mode, names) { function callExport(callback, mode, names) {
let alert = api.feature.work_alerts ? api.show.alert("Exporting") : null; let alert = api.feature.work_alerts ? api.show.alert("Exporting") : null;
let gcode = []; let gcode = [];
@ -65,6 +82,12 @@ function callExport(callback, mode, names) {
}); });
} }
/**
* Export laser/waterjet/drag knife toolpaths.
* Worker generates output, then presents export dialog with SVG/DXF/STL/GCode options.
* @param {object} options - Export options
* @param {string[]} names - Widget filenames for export naming
*/
function callExportLaser(options, names) { function callExportLaser(options, names) {
client.export(api.conf.get(), (line) => { client.export(api.conf.get(), (line) => {
// engine export uses lines // engine export uses lines
@ -79,6 +102,12 @@ function callExportLaser(options, names) {
}); });
} }
/**
* Export SLA print data.
* Worker generates layer images, then delegates to SLA client for download.
* @param {object} options - Export options
* @param {string[]} names - Widget filenames for export naming
*/
function callExportSLA(options, names) { function callExportSLA(options, names) {
client.export(api.conf.get(), (line) => { client.export(api.conf.get(), (line) => {
api.show.progress(line.progress, "exporting"); api.show.progress(line.progress, "exporting");
@ -92,6 +121,12 @@ function callExportSLA(options, names) {
}); });
} }
/**
* Present laser export dialog with format options (SVG, DXF, STL, GCode).
* Sets up UI handlers for downloading in each supported format.
* @param {Array} data - Layer data from export worker
* @param {string[]} names - Widget filenames for naming suggestion
*/
function exportLaserDialog(data, names) { function exportLaserDialog(data, names) {
localSet('kiri-print-seq', printSeq++); localSet('kiri-print-seq', printSeq++);
@ -101,9 +136,9 @@ function exportLaserDialog(data, names) {
const driver = laser_driver; const driver = laser_driver;
const { process } = settings; const { process } = settings;
if (process.ctOutStack) { if (process.ctOutStack) {
$('print-stl').classList.remove('hide'); $('print-obj').classList.remove('hide');
} else { } else {
$('print-stl').classList.add('hide'); $('print-obj').classList.add('hide');
} }
function download_svg() { function download_svg() {
@ -120,10 +155,10 @@ function exportLaserDialog(data, names) {
); );
} }
function download_stl() { function download_obj() {
api.util.download( api.util.download(
driver.exportSTL(settings, data), driver.exportOBJ(settings, data),
$('print-filename-laser').value + ".stl" $('print-filename-laser').value + ".obj"
); );
} }
@ -143,17 +178,36 @@ function exportLaserDialog(data, names) {
$('print-lines').value = util.comma(segments); $('print-lines').value = util.comma(segments);
$('print-svg').onclick = download_svg; $('print-svg').onclick = download_svg;
$('print-dxf').onclick = download_dxf; $('print-dxf').onclick = download_dxf;
$('print-stl').onclick = download_stl; $('print-obj').onclick = download_obj;
$('print-lg').onclick = download_gcode; $('print-lg').onclick = download_gcode;
} }
/**
* Bind an input field to persist its value to local storage on blur.
* @param {string} field - Element ID of the input field
* @param {string} varname - Local storage key to save value under
*/
function bindField(field, varname) { function bindField(field, varname) {
$(field).onblur = function() { $(field).onblur = function() {
console.log('save', field, 'to', varname); console.log('save', field, 'to', varname);
localSet(varname, $(field).value.trim()); localSet(varname, $(field).value.trim());
}; };
$(field).onchange = $(field).onblur;
} }
/**
* Present gcode export dialog with download/send options.
* Complex UI setup that handles:
* - Local download of gcode, zip (CAM operations), or 3MF (FDM)
* - OctoPrint integration for remote printing
* - Bambu printer integration (via api.bambu)
* - Print statistics (time, filament, weight)
* - Gcode preview
* @param {string[]} gcode - Array of gcode lines
* @param {object} [sections] - CAM operation sections for zip export
* @param {object} info - Export metadata (time, distance, bytes, etc.)
* @param {string[]} names - Widget filenames for naming suggestion
*/
function exportGCodeDialog(gcode, sections, info, names) { function exportGCodeDialog(gcode, sections, info, names) {
localSet('kiri-print-seq', printSeq++); localSet('kiri-print-seq', printSeq++);
@ -166,12 +220,7 @@ function exportGCodeDialog(gcode, sections, info, names) {
codeproc = settings.device.gcodeProc, codeproc = settings.device.gcodeProc,
octo_host, octo_host,
octo_apik, octo_apik,
grid_host, octo_type;
grid_apik,
grid_target,
grid_targets = {},
grid_local,
grid_uuid;
// join gcode array into a string // join gcode array into a string
gcode = gcode.join('\r\n') + '\r\n'; gcode = gcode.join('\r\n') + '\r\n';
@ -187,13 +236,18 @@ function exportGCodeDialog(gcode, sections, info, names) {
{type:"application/octet-stream"}); {type:"application/octet-stream"});
} }
function sendto_octoprint() { function printToHost() {
if (!(octo_host && octo_apik)) return; sendToHost(true);
}
function sendToHost(start) {
if (!(octo_host && octo_apik && octo_type)) return;
let form = new FormData(), let form = new FormData(),
ajax = new XMLHttpRequest(), ajax = new XMLHttpRequest(),
host = octo_host.value.toLowerCase(), host = octo_host.value.toLowerCase(),
apik = octo_apik.value; apik = octo_apik.value,
type = octo_type.value;
if (host.indexOf("http") !== 0) { if (host.indexOf("http") !== 0) {
api.show.alert("host missing protocol (http:// or https://)"); api.show.alert("host missing protocol (http:// or https://)");
@ -206,26 +260,43 @@ function exportGCodeDialog(gcode, sections, info, names) {
localSet('octo-host', host.trim()); localSet('octo-host', host.trim());
localSet('octo-apik', apik.trim()); localSet('octo-apik', apik.trim());
localSet('octo-type', type.trim());
filename = $('print-filename').value; filename = $('print-filename').value + "." + fileext;
form.append("file", getBlob(), filename+"."+fileext); form.append("file", getBlob(), filename);
ajax.onreadystatechange = function() { ajax.onreadystatechange = function() {
console.log({ ajax_readyState: ajax.readyState });
if (ajax.readyState === 4) { if (ajax.readyState === 4) {
let status = ajax.status; let status = ajax.status;
api.stats.add(`ua_${api.mode.get_lower()}_print_octo_${status}`); api.stats.add(`ua_${api.mode.get_lower()}_print_send_${status}`);
if (status >= 200 && status < 300) { if (status >= 200 && status < 300) {
api.modal.hide(); api.modal.hide();
} else { } else {
api.show.alert("octoprint error\nstatus: "+status+"\nmessage: "+ajax.responseText); api.show.alert("send error\nstatus: "+status+"\nmessage: "+ajax.responseText);
} }
api.show.progress(0); api.show.progress(0);
if (start && type === 'moonraker') {
fetch(`${host}/printer/print/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename })
})
.then(r => r.json())
.then(data => console.log('print start', data))
.catch(err => console.error('print start error', err))
}
} }
api.show.progress(0); api.show.progress(0);
}; };
ajax.upload.addEventListener('progress', function(evt) { ajax.upload.addEventListener('progress', (evt) => {
console.log({ ajax_progress: evt });
api.show.progress(evt.loaded/evt.total, "sending"); api.show.progress(evt.loaded/evt.total, "sending");
}); });
ajax.open("POST", host+"/api/files/local"); if (type === 'moonraker') {
ajax.open("POST", host + "/server/files/upload");
} else {
ajax.open("POST", host + "/api/files/local");
}
if (apik) { if (apik) {
ajax.setRequestHeader("X-Api-Key", apik); ajax.setRequestHeader("X-Api-Key", apik);
} }
@ -275,7 +346,8 @@ function exportGCodeDialog(gcode, sections, info, names) {
api.modal.show('xany'); api.modal.show('xany');
let set = api.conf.get(); let set = api.conf.get();
let fdm = MODE === MODES.FDM; let fdm = MODE === MODES.FDM;
let octo = set.controller.exportOcto && MODE !== MODES.CAM; let cam = MODE === MODES.CAM;
let octo = set.controller.exportOcto && !cam;
let preview = set.controller.exportPreview; let preview = set.controller.exportPreview;
$('code-preview-head').style.display = preview ? '' : 'none'; $('code-preview-head').style.display = preview ? '' : 'none';
$('code-preview').style.display = preview ? '' : 'none'; $('code-preview').style.display = preview ? '' : 'none';
@ -284,8 +356,8 @@ function exportGCodeDialog(gcode, sections, info, names) {
$('print-filename').value = filename; $('print-filename').value = filename;
$('print-filesize').value = util.comma(info.bytes); $('print-filesize').value = util.comma(info.bytes);
$('print-length').value = Math.round(info.distance); $('print-length').value = Math.round(info.distance);
if (set.controller.devel) { if ((fdm || cam) && preview && set.controller.devel) {
$('code-preview-textarea').style.height = "30em"; document.body.classList.add('devel');
} }
calcTime(); calcTime();
if (fdm) { if (fdm) {
@ -295,6 +367,7 @@ function exportGCodeDialog(gcode, sections, info, names) {
// persist fields when changed // persist fields when changed
bindField('octo-host', 'octo-host'); bindField('octo-host', 'octo-host');
bindField('octo-apik', 'octo-apik'); bindField('octo-apik', 'octo-apik');
bindField('octo-type', 'octo-type');
// in cam mode, show zip file option // in cam mode, show zip file option
let downloadZip = $('print-zip'); let downloadZip = $('print-zip');
@ -336,6 +409,13 @@ function exportGCodeDialog(gcode, sections, info, names) {
// let bnds = settings.bounds; // let bnds = settings.bounds;
// console.log({ wids, bnds }); // console.log({ wids, bnds });
/**
* Generate 3MF file for Bambu/BambuStudio printers.
* Creates ZIP archive with gcode, thumbnails, and metadata.
* @param {function} then - Callback to receive generated 3MF blob
* @param {string} [ptype='unknown'] - Printer type identifier
* @param {number[]} [ams=[0]] - AMS (filament) slot assignments
*/
function gen3mf(then, ptype = 'unknown', ams = [0]) { function gen3mf(then, ptype = 'unknown', ams = [0]) {
let now = new Date(); let now = new Date();
let ymd = [ let ymd = [
@ -489,21 +569,23 @@ function exportGCodeDialog(gcode, sections, info, names) {
}) })
}; };
// octoprint setup // remote host (klipper fluidd / octoprint)
$('send-to-octohead').style.display = octo ? '' : 'none'; $('send-remote-head').style.display = octo ? '' : 'none';
$('send-to-octoprint').style.display = octo ? '' : 'none'; $('send-remote-print').style.display = octo ? '' : 'none';
if (octo) try { if (octo) try {
$('print-octoprint').onclick = sendto_octoprint; $('send-remote').onclick = sendToHost;
$('print-remote').onclick = printToHost;
octo_host = $('octo-host'); octo_host = $('octo-host');
octo_apik = $('octo-apik'); octo_apik = $('octo-apik');
octo_type = $('octo-type');
if (MODE === MODES.CAM) { if (MODE === MODES.CAM) {
$('send-to-octohead').style.display = 'none'; $('send-remote-head').style.display = 'none';
$('send-to-octoprint').style.display = 'none'; $('send-remote-print').style.display = 'none';
} else { } else {
$('send-to-octohead').style.display = ''; $('send-remote-head').style.display = '';
$('send-to-octoprint').style.display = ''; $('send-remote-print').style.display = '';
} }
// hide octoprint when hard-coded in the url // hide remote host option when hard-coded in the url
if (api.const.OCTO) { if (api.const.OCTO) {
$('ophost').style.display = 'none'; $('ophost').style.display = 'none';
$('opapik').style.display = 'none'; $('opapik').style.display = 'none';
@ -511,6 +593,7 @@ function exportGCodeDialog(gcode, sections, info, names) {
} }
octo_host.value = localGet('octo-host') || ''; octo_host.value = localGet('octo-host') || '';
octo_apik.value = localGet('octo-apik') || ''; octo_apik.value = localGet('octo-apik') || '';
octo_type.value = localGet('octo-type') || '';
} catch (e) { console.log(e) } } catch (e) { console.log(e) }
// preview of the generated GCODE (first 64k max) // preview of the generated GCODE (first 64k max)

View file

@ -1,20 +1,39 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */ /** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
/**
* Face selection tools for mesh manipulation.
* Provides interactive tools for:
* - Lay flat: Rotate selected face to be parallel with build plate
* - Left align: Rotate to align face normal with -X axis
* - Face up: Select face normal for custom operations
* - Camera focus: Select point for camera focus
*
* Tools use hover mode to display visual indicator on face under cursor.
* User clicks face to apply operation. ESC to cancel.
*/
import { api } from './api.js'; import { api } from './api.js';
import { THREE } from '../../ext/three.js'; import { THREE } from '../../ext/three.js';
import { space } from '../../moto/space.js'; import { space } from '../../moto/space.js';
const { Vector3, Quaternion } = THREE; const { Vector3, Quaternion } = THREE;
/** X-axis unit vector for rotation calculations */
const XAXIS = new THREE.Vector3(1,0,0); const XAXIS = new THREE.Vector3(1,0,0);
/** Z-axis unit vector for rotation calculations */
const ZAXIS = new THREE.Vector3(0,0,1); const ZAXIS = new THREE.Vector3(0,0,1);
// circle /** Circle mesh geometry for face indicator */
let pgeo = new THREE.CircleGeometry(8, 30); let pgeo = new THREE.CircleGeometry(8, 30);
/** Semi-transparent material for face indicator */
let pmat = new THREE.MeshBasicMaterial({color: 0xddeeff, opacity: 0.6, transparent: true}); let pmat = new THREE.MeshBasicMaterial({color: 0xddeeff, opacity: 0.6, transparent: true});
/** Combined mesh for face indicator (circle + outline) */
let pmesh = new THREE.Mesh(pgeo, pmat); let pmesh = new THREE.Mesh(pgeo, pmat);
// circle outline // Build circle outline from geometry indices
let pi = pgeo.index.array; let pi = pgeo.index.array;
let pp = pgeo.attributes.position.array; let pp = pgeo.attributes.position.array;
let np = []; let np = [];
@ -36,19 +55,39 @@ let wmat = new THREE.LineBasicMaterial({ color: 0x6699cc });
let wmesh = new THREE.LineSegments(wgeo, wmat); let wmesh = new THREE.LineSegments(wgeo, wmat);
pmesh.add(wmesh); pmesh.add(wmesh);
/** Alert handle for "ESC to end" message */
let alert; let alert;
/** Last object (widget mesh) the indicator was attached to */
let lastobj; let lastobj;
/** Last face that was hovered over */
let lastface; let lastface;
/** Whether a tool is currently active */
let enabled = false; let enabled = false;
/** Name of current operation for display */
let opName = 'lay flat'; let opName = 'lay flat';
/** Callback to execute when face is selected */
let onDone = onLayFlatSelect; let onDone = onLayFlatSelect;
/**
* Lay flat operation: Rotate widget so selected face is parallel to build plate.
* Calculates quaternion to align face normal with -Z axis (down).
*/
function onLayFlatSelect() { function onLayFlatSelect() {
let q = new Quaternion().setFromUnitVectors(lastface.normal, new Vector3(0,0,-1)); let q = new Quaternion().setFromUnitVectors(lastface.normal, new Vector3(0,0,-1));
api.selection.rotate(q); api.selection.rotate(q);
// endIt(); // endIt();
} }
/**
* Left align operation: Rotate widget so selected face normal aligns with -X axis.
* Projects face normal onto XY plane and calculates Z-axis rotation.
* Used for orienting parts for belt printers or side printing.
*/
function onLeftySelect() { function onLeftySelect() {
// Project the face normal onto the XY plane (ignore Z component) // Project the face normal onto the XY plane (ignore Z component)
let projectedNormal = new Vector3(lastface.normal.x, lastface.normal.y, 0).normalize(); let projectedNormal = new Vector3(lastface.normal.x, lastface.normal.y, 0).normalize();
@ -67,11 +106,21 @@ function onLeftySelect() {
api.selection.rotate(q); api.selection.rotate(q);
} }
/**
* Face select operation: Emit face normal for custom handling.
* Emits 'tool.mesh.face-normal' event with selected face normal.
*/
function onFaceUpSelect() { function onFaceUpSelect() {
api.event.emit('tool.mesh.face-normal', lastface.normal); api.event.emit('tool.mesh.face-normal', lastface.normal);
// endIt(); // endIt();
} }
/**
* Start interactive face selection tool.
* Enables hover mode, displays alert, and begins tracking mouse.
* If already enabled, ends the current operation.
* Cannot start if another hover mode is active.
*/
function startIt() { function startIt() {
if (enabled) { if (enabled) {
endIt(); endIt();
@ -86,6 +135,10 @@ function startIt() {
alert = api.show.alert(`[ESC] to end ${opName}`, 600000); alert = api.show.alert(`[ESC] to end ${opName}`, 600000);
} }
/**
* End current face selection tool.
* Disables hover mode, hides alert, and cleans up indicator mesh.
*/
function endIt() { function endIt() {
if (enabled) { if (enabled) {
api.hide.alert(alert); api.hide.alert(alert);
@ -96,6 +149,9 @@ function endIt() {
} }
} }
/**
* Remove face indicator mesh from scene.
*/
function cleanup() { function cleanup() {
if (lastobj) { if (lastobj) {
lastobj.remove(pmesh); lastobj.remove(pmesh);
@ -105,30 +161,52 @@ function cleanup() {
api.event.on('key.esc', endIt); api.event.on('key.esc', endIt);
api.event.on('tool.camera.focus', (fn) => { /**
* Start camera focus tool.
* User clicks face to set camera focus point.
* @param {Function} fn - Callback to execute with focus point
*/
function startFocus(fn) {
opName = 'camera focus'; opName = 'camera focus';
onDone = fn; onDone = fn;
startIt(); startIt();
}); }
api.event.on('tool.mesh.face-up', () => { /**
* Start face selection tool.
* User clicks face to select its normal vector.
*/
function startFaceUp() {
opName = 'face select'; opName = 'face select';
onDone = onFaceUpSelect; onDone = onFaceUpSelect;
startIt(); startIt();
}); }
api.event.on('tool.mesh.lay-flat', () => { /**
* Start lay flat tool.
* User clicks face to rotate widget so that face is parallel to build plate.
*/
function startLayFlat() {
opName = 'lay flat'; opName = 'lay flat';
onDone = onLayFlatSelect; onDone = onLayFlatSelect;
startIt(); startIt();
}); }
api.event.on('tool.mesh.lefty', () => { /**
* Start left align tool.
* User clicks face to align its normal with -X axis.
*/
function startLeftAlign() {
opName = 'align y axis'; opName = 'align y axis';
onDone = onLeftySelect; onDone = onLeftySelect;
startIt(); startIt();
}); }
/**
* Scale face indicator based on camera distance.
* For camera focus, scales inversely (closer = smaller).
* For other tools, scales with distance for consistent visual size.
*/
function scale() { function scale() {
let cam = space.internals().camera; let cam = space.internals().camera;
let dist = cam.position.distanceTo(pmesh.position); let dist = cam.position.distanceTo(pmesh.position);
@ -137,8 +215,6 @@ function scale() {
pmesh.scale.set(scale,scale,scale); pmesh.scale.set(scale,scale,scale);
} }
api.event.on("space.view.zoom", scale);
api.event.on('mouse.hover', (ev) => { api.event.on('mouse.hover', (ev) => {
if (!enabled) { if (!enabled) {
return; return;
@ -186,5 +262,16 @@ api.event.on('mouse.hover.up', (ev) => {
endIt(); endIt();
}); });
export {
export { onLayFlatSelect, onFaceUpSelect, startIt, endIt, cleanup, scale, onDone }; cleanup,
endIt,
onDone,
onFaceUpSelect,
onLayFlatSelect,
scale,
startFaceUp,
startFocus,
startIt,
startLeftAlign,
startLayFlat,
};

159
src/kiri/app/file-ops.js Normal file
View file

@ -0,0 +1,159 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { api } from './api.js';
import { base } from '../../geo/base.js';
import { space } from '../../moto/space.js';
const DOC = self.document;
/** Cached platform color before drag-over highlight */
let platformColor;
/**
* Handle file drag-over event.
* Changes platform color to green to indicate drop target.
* Prevents drop when modal dialogs are open.
* @param {DragEvent} evt - Browser drag event
*/
function dragOverHandler(evt) {
evt.stopPropagation();
evt.preventDefault();
// prevent drop actions when a dialog is open
if (api.modal.visible()) {
return;
}
evt.dataTransfer.dropEffect = 'copy';
let oldcolor = space.platform.setColor(0x00ff00);
if (oldcolor !== 0x00ff00) platformColor = oldcolor;
}
/**
* Handle drag-leave event by restoring original platform color.
*/
function dragLeave() {
space.platform.setColor(platformColor);
}
/**
* Handle file drop event.
* Restores platform color and loads dropped files.
* Handles multi-file drops with optional grouping:
* - Single file: loads immediately
* - Multiple files: prompts user whether to group
* - api.feature.drop_group can override behavior (true=always group, false=never group)
* @param {DragEvent} evt - Browser drop event
*/
function dropHandler(evt) {
evt.stopPropagation();
evt.preventDefault();
// prevent drop actions when a dialog is open
if (api.modal.visible()) {
return;
}
space.platform.setColor(platformColor);
let files = evt.dataTransfer.files;
switch (api.feature.drop_group) {
case true:
return api.platform.load_files(files, []);
case false:
return api.platform.load_files(files, undefined);
}
if (files.length === 1) {
api.platform.load_files(files);
} else if (files.length > 1) {
api.uc.confirm(`group ${files.length} files?`).then(yes => {
api.platform.load_files(files, yes ? [] : undefined);
});
}
}
/**
* Load a file from the catalog when clicked.
* @param {Event} e - Click event from catalog item
*/
function loadCatalogFile(e) {
api.widgets.load(e.target.getAttribute('load'), function(widget) {
api.platform.add(widget);
api.dialog.hide();
});
}
/**
* Update the catalog UI with current file list.
* Builds interactive list with rename, load, and delete buttons for each file.
* Sorts files alphabetically (case-insensitive).
* @param {object} files - Dictionary of filename -> {vertices, updated} file metadata
*/
function updateCatalog(files) {
let table = api.ui.catalog.list,
list = [];
table.innerHTML = '';
for (let name in files) {
list.push({n:name, ln:name.toLowerCase(), v:files[name].vertices, t:files[name].updated});
}
list.sort(function(a,b) {
return a.ln < b.ln ? -1 : 1;
});
for (let i=0; i<list.length; i++) {
let row = DOC.createElement('div'),
renm = DOC.createElement('button'),
load = DOC.createElement('button'),
size = DOC.createElement('button'),
del = DOC.createElement('button'),
file = list[i],
name = file.n,
date = new Date(file.t),
split = name.split('.'),
short = split[0],
ext = split[1] ? `.${split[1]}` : '';
renm.setAttribute('class', 'rename');
renm.setAttribute('title', 'rename file');
renm.innerHTML = '<i class="far fa-edit"></i>';
renm.onclick = () => {
api.uc.prompt(`rename file`, short).then(newname => {
if (newname && newname !== short) {
api.catalog.rename(name, `${newname}${ext}`, then => {
api.modal.show('files');
});
}
});
};
load.setAttribute('load', name);
load.setAttribute('title', `file: ${name}\nvertices: ${file.v}\ndate: ${date}`);
load.onclick = loadCatalogFile;
load.appendChild(DOC.createTextNode(short));
del.setAttribute('del', name);
del.setAttribute('title', "remove '"+name+"'");
del.onclick = () => { api.catalog.deleteFile(name) };
del.innerHTML = '<i class="far fa-trash-alt"></i>';
size.setAttribute("disabled", true);
size.setAttribute("class", "label");
size.appendChild(DOC.createTextNode(base.util.comma(file.v)));
row.setAttribute("class", "f-row a-center");
row.appendChild(renm);
row.appendChild(load);
row.appendChild(size);
row.appendChild(del);
table.appendChild(row);
}
}
export const fileOps = {
dragOverHandler,
dragLeave,
dropHandler,
loadCatalogFile,
updateCatalog
};

View file

@ -1,6 +1,15 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */ /** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
/**
* Manages file storage in IndexedDB for 3D model vertices.
* Supports file operations (put, get, delete, rename), listener notifications,
* and deferred loading for externally-stored files.
*/
class Files { class Files {
/**
* Create a new Files store
* @param {object} indexdb - IndexedDB wrapper instance
*/
constructor(indexdb) { constructor(indexdb) {
let store = this; let store = this;
this.db = indexdb; this.db = indexdb;
@ -47,10 +56,21 @@ class Files {
this.listeners.remove(listener); this.listeners.remove(listener);
}; };
/**
* Set handler for deferred file loading.
* Deferred files are not stored locally but fetched on-demand.
* @param {function} handler - Function(mark, name, callback) to load deferred file
*/
setDeferredHandler(handler) { setDeferredHandler(handler) {
this.deferredHandler = handler; this.deferredHandler = handler;
}; };
/**
* Register a deferred file placeholder.
* File data is not stored locally; the mark is used by the deferred handler to fetch it later.
* @param {string} name - Filename
* @param {*} mark - Identifier for deferred handler to fetch file (e.g., URL, storage key)
*/
putDeferred(name, mark) { putDeferred(name, mark) {
// triggers refresh callback // triggers refresh callback
this.files[name] = { this.files[name] = {
@ -91,6 +111,13 @@ class Files {
.catch(ondone); .catch(ondone);
}; };
/**
* Rename a file in the catalog.
* Copies vertex data to new key, updates file list, and removes old key.
* @param {string} name - Current filename
* @param {string} newname - New filename
* @param {function} callback - Function({error}) called when complete
*/
rename(name, newname, callback) { rename(name, newname, callback) {
if (!this.files[name]) return callback({error: 'no such file'}); if (!this.files[name]) return callback({error: 'no such file'});
if (!newname || newname == name) return callback({error: 'invalid new name'}); if (!newname || newname == name) return callback({error: 'invalid new name'});
@ -146,6 +173,12 @@ class Files {
if (callback) callback(false); if (callback) callback(false);
}; };
/**
* Delete files matching a filter function.
* @param {function} fn - Filter function(key) returning true to delete
* @param {*} [from] - Optional start key for range query
* @param {*} [to] - Optional end key for range query
*/
deleteFilter(fn, from, to) { deleteFilter(fn, from, to) {
this.db.keys(keys => { this.db.keys(keys => {
for (let key of keys) { for (let key of keys) {
@ -157,17 +190,30 @@ class Files {
} }
} }
/**
* Save file list to IndexedDB and notify listeners.
* @param {Files} store - Files instance
*/
function saveFileList(store) { function saveFileList(store) {
store.db.put('files', store.files); store.db.put('files', store.files);
notifyFileListeners(store); notifyFileListeners(store);
} }
/**
* Notify all registered listeners of file list changes.
* @param {Files} store - Files instance
*/
function notifyFileListeners(store) { function notifyFileListeners(store) {
for (let i=0; i<store.listeners.length; i++) { for (let i=0; i<store.listeners.length; i++) {
store.listeners[i](store.files); store.listeners[i](store.files);
} }
} }
/**
* Factory function to create a new Files instance.
* @param {object} indexdb - IndexedDB wrapper instance
* @returns {Files} New Files store
*/
export const openFiles = function(indexdb) { export const openFiles = function(indexdb) {
return new Files(indexdb); return new Files(indexdb);
}; };

View file

@ -1,12 +1,38 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */ /** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
/**
* Frame Message API for iframe integration.
*
* Enables parent windows to control embedded Kiri:Moto instances via postMessage.
* Controlled by api.feature.frame flag.
*
* Supported message types:
* - mode: Set operating mode (FDM, CAM, SLA, LASER, etc.)
* - view: Set view mode (ARRANGE, SLICE, PREVIEW, etc.)
* - function: Call api.function methods (slice, print, export, etc.)
* - event: Subscribe to events
* - emit: Emit events
* - get: Query state (mode, device, process, widgets)
* - set: Update state
* - features: Update feature flags
* - device/process/controller: Update settings
* - parse: Parse and load file data (STL, OBJ, 3MF, SVG)
* - load: Load URL
* - clear: Clear platform
* - alert: Show alert
* - progress: Update progress bar
*/
import { api } from './api.js'; import { api } from './api.js';
import { load } from '../../load/file.js'; import { load } from '../../load/file.js';
import { newWidget } from './widget.js'; import { newWidget } from './widget.js';
import { VIEWS } from './consts.js'; import { VIEWS } from './consts.js';
import { widgets } from './widgets.js'; import { widgets } from './widgets.js';
// add frame message api listener /**
* Frame message API listener.
* Handles postMessage communication between parent window and embedded Kiri:Moto.
*/
window.addEventListener('message', msg => { window.addEventListener('message', msg => {
const { conf, event, feature, platform, settings, show } = api; const { conf, event, feature, platform, settings, show } = api;
@ -89,6 +115,9 @@ window.addEventListener('message', msg => {
let bin = data.parse; let bin = data.parse;
let widget; let widget;
switch ((data.type || 'stl').toLowerCase()) { switch ((data.type || 'stl').toLowerCase()) {
case 'gcode':
api.function.parse(bin, 'gcode');
break;
case 'stl': case 'stl':
if (!bin.buffer) bin = new Float32Array(bin).buffer; if (!bin.buffer) bin = new Float32Array(bin).buffer;
new load.STL().parse(bin, vertices => { new load.STL().parse(bin, vertices => {

View file

@ -1,14 +1,43 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */ /** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { api } from './api.js'; import { api } from './api.js';
import { client } from './client.js'; import { client } from './workers.js';
import { codec } from './codec.js'; import { codec } from '../core/codec.js';
import { space } from '../../moto/space.js'; import { space } from '../../moto/space.js';
import { COLOR, PMODES } from './consts.js'; import { PMODES } from './consts.js';
import { colorSchemeRegistry } from './color/schemes.js';
import { exportFile } from './export.js'; import { exportFile } from './export.js';
let complete = {}; /**
* Get current color scheme
*/
function getColorScheme() {
const mode = api.mode.get_id();
const theme = api.space.is_dark() ? 'dark' : 'light';
return colorSchemeRegistry.getScheme(mode, theme);
}
/**
* Tracks completion state of operations to prevent redundant work.
* Properties: slice, preview, export
*/
let complete = {};
let order;
/**
* Prepare and execute slicing for all widgets on the platform.
* Main slicing function that:
* - Takes screenshots for export/preview
* - Handles belt mode layout
* - Slices each widget sequentially via worker
* - Tracks progress across all widgets
* - Renders sliced layers to stacks
* - Emits slice.begin, slice, slice.end, slice.error events
*
* @param {function} [callback] - Called when slicing completes
* @param {number} [scale=1] - Progress bar scale factor (for chaining operations)
* @param {number} [offset=0] - Progress bar offset (for chaining operations)
*/
function prepareSlices(callback, scale = 1, offset = 0) { function prepareSlices(callback, scale = 1, offset = 0) {
const { conf, event, feature, hide, mode, view, platform, show, stacks } = api; const { conf, event, feature, hide, mode, view, platform, show, stacks } = api;
@ -28,6 +57,10 @@ function prepareSlices(callback, scale = 1, offset = 0) {
callback = preparePreview; callback = preparePreview;
} }
order = api.selection.count() ?
Object.assign({}, ...api.selection.widgets().map((w,i) => ({[w.id]:i}))) :
undefined;
const widgets = api.widgets.all(); const widgets = api.widgets.all();
const settings = conf.get(); const settings = conf.get();
const { device, process, controller } = settings; const { device, process, controller } = settings;
@ -73,7 +106,8 @@ function prepareSlices(callback, scale = 1, offset = 0) {
let totalProgress; let totalProgress;
api.widgets.setOpacity(COLOR.slicing_opacity); const scheme = getColorScheme();
api.widgets.setOpacity(scheme.views.SLICE.slicing_opacity);
let segtimes = {}, let segtimes = {},
segNumber = 0, segNumber = 0,
@ -87,7 +121,8 @@ function prepareSlices(callback, scale = 1, offset = 0) {
for (let widget of toSlice) { for (let widget of toSlice) {
widget.belt = null; widget.belt = null;
widget.stats.progress = 0; widget.stats.progress = 0;
widget.setColor(COLOR.slicing); const scheme = getColorScheme();
widget.setColor(0xffaaaa); // slicing color (from old COLOR.slicing)
let { extruder } = widget.anno; let { extruder } = widget.anno;
if (extruder >= 0) { if (extruder >= 0) {
extruders[extruder] = extruder; extruders[extruder] = extruder;
@ -114,13 +149,17 @@ function prepareSlices(callback, scale = 1, offset = 0) {
client.rotate(settings); client.rotate(settings);
} }
function sliceSetup() {
client.slicePre(settings, sliceNext);
}
function sliceNext() { function sliceNext() {
if (toSlice.length) { if (toSlice.length) {
// while more widgets to slice // while more widgets to slice
sliceWidget(toSlice.shift()) sliceWidget(toSlice.shift())
} else { } else {
// once all slicing done, run sliceAll() once for all widgets // once all slicing done, run slicePost() once for all widgets
client.sliceAll(settings, sliceDone); client.slicePost(settings, sliceDone);
} }
} }
@ -134,8 +173,8 @@ function prepareSlices(callback, scale = 1, offset = 0) {
let mark = Date.now(); let mark = Date.now();
if (lastMsg) { if (lastMsg) {
let key = slicing.length > 1 ? let key = slicing.length > 1 ?
`${widget.id}_${segNumber++}_${lastMsg}` : `${widget.id} ${segNumber++} ${lastMsg}` :
`${segNumber++}_${lastMsg}` `${segNumber++} ${lastMsg}`
segtimes[key] = mark - startTime; segtimes[key] = mark - startTime;
} }
lastMsg = msg; lastMsg = msg;
@ -159,8 +198,8 @@ function prepareSlices(callback, scale = 1, offset = 0) {
// update segment time // update segment time
if (lastMsg) { if (lastMsg) {
let key = slicing.length > 1 ? let key = slicing.length > 1 ?
`${widget.id}_${segNumber++}_${lastMsg}` : `${widget.id} ${segNumber++} ${lastMsg}` :
`${segNumber++}_${lastMsg}` `${segNumber++} ${lastMsg}`
segtimes[`${key}`] = mark - startTime; segtimes[`${key}`] = mark - startTime;
} }
event.emit('slice', settings.mode); event.emit('slice', settings.mode);
@ -241,8 +280,8 @@ function prepareSlices(callback, scale = 1, offset = 0) {
for (let widget of slicing) { for (let widget of slicing) {
// on done // on done
let key = slicing.length > 1 ? let key = slicing.length > 1 ?
`${widget.id}_${segNumber++}_draw` : `${widget.id}_${segNumber++} draw` :
`${segNumber++}_draw` `${segNumber++} draw`
segtimes[`${key}`] = widget.render(widget.stack); segtimes[`${key}`] = widget.render(widget.stack);
// rotate stack for belt beds // rotate stack for belt beds
if (widget.belt) { if (widget.belt) {
@ -250,9 +289,10 @@ function prepareSlices(callback, scale = 1, offset = 0) {
} }
if (scale === 1) { if (scale === 1) {
// clear wireframe // clear wireframe
widget.setWireframe(false, COLOR.wireframe, COLOR.wireframe_opacity); const scheme = getColorScheme();
widget.setOpacity(camOrLaser ? COLOR.cam_sliced_opacity : COLOR.sliced_opacity); widget._setWireframe(false, scheme.wireframe.color, scheme.wireframe.opacity);
widget.setColor(COLOR.deselected); widget.setOpacity(camOrLaser ? scheme.views.SLICE.sliced_opacity ?? 0.2 : scheme.views.PREVIEW?.sliced_opacity ?? 0.0);
widget.setColor(scheme.widget.deselected);
api.hide.alert(alert); api.hide.alert(alert);
} }
} }
@ -273,6 +313,7 @@ function prepareSlices(callback, scale = 1, offset = 0) {
// print stats // print stats
segtimes.total = Date.now() - mark; segtimes.total = Date.now() - mark;
console.log(segtimes); console.log(segtimes);
api.visuals.update_stats(segtimes);
if (callback && typeof callback === 'function') { if (callback && typeof callback === 'function') {
callback(); callback();
} }
@ -285,10 +326,22 @@ function prepareSlices(callback, scale = 1, offset = 0) {
space.refresh(); space.refresh();
} }
// kick off slicing chain // do any global setup / state management
sliceNext(); // then kick off slicing chain
sliceSetup();
} }
/**
* Prepare preview/print visualization.
* Generates toolpaths and renders them as 3D lines.
* Handles multiple preview modes (speed, filament, layer) via feature.pmode.
* Auto-runs slicing first if not already complete.
* Emits preview.begin, print, preview.end, preview.error events.
*
* @param {function} [callback] - Called when preview completes
* @param {number} [scale=1] - Progress bar scale factor (for chaining operations)
* @param {number} [offset=0] - Progress bar offset (for chaining operations)
*/
function preparePreview(callback, scale = 1, offset = 0) { function preparePreview(callback, scale = 1, offset = 0) {
const { conf, event, feature, hide, mode, view, platform, show, stacks } = api; const { conf, event, feature, hide, mode, view, platform, show, stacks } = api;
const widgets = api.widgets.all(); const widgets = api.widgets.all();
@ -325,10 +378,12 @@ function preparePreview(callback, scale = 1, offset = 0) {
event.emit('preview.begin', pMode); event.emit('preview.begin', pMode);
if (isCam) { if (isCam) {
api.widgets.setOpacity(isDark ? COLOR.cam_preview_opacity_dark : COLOR.cam_preview_opacity); const scheme = getColorScheme();
api.widgets.setColor(isDark ? COLOR.cam_preview_dark : COLOR.cam_preview); api.widgets.setOpacity(scheme.views.PREVIEW.preview_opacity);
api.widgets.setColor(scheme.views.PREVIEW.preview);
} else if (offset === 0) { } else if (offset === 0) {
api.widgets.setOpacity(COLOR.preview_opacity); const scheme = getColorScheme();
api.widgets.setOpacity(scheme.views.PREVIEW.preview_opacity);
} }
let mark = Date.now(), let mark = Date.now(),
@ -341,6 +396,7 @@ function preparePreview(callback, scale = 1, offset = 0) {
// pass preview mode to worker // pass preview mode to worker
settings.pmode = feature.pmode; settings.pmode = feature.pmode;
settings.render = true; settings.render = true;
settings.order = order;
client.prepare(settings, (progress, message, layer) => { client.prepare(settings, (progress, message, layer) => {
if (layer) { if (layer) {
@ -349,7 +405,7 @@ function preparePreview(callback, scale = 1, offset = 0) {
if (message && message !== lastMsg) { if (message && message !== lastMsg) {
const mark = Date.now(); const mark = Date.now();
if (lastMsg) { if (lastMsg) {
segtimes[`${segNumber++}_${lastMsg}`] = mark - startTime; segtimes[`${segNumber++} ${lastMsg}`] = mark - startTime;
} }
lastMsg = message; lastMsg = message;
startTime = mark; startTime = mark;
@ -367,7 +423,7 @@ function preparePreview(callback, scale = 1, offset = 0) {
} }
if (lastMsg) { if (lastMsg) {
segtimes[`${segNumber++}_${lastMsg}`] = Date.now() - startTime; segtimes[`${segNumber++} ${lastMsg}`] = Date.now() - startTime;
} }
show.progress(0); show.progress(0);
@ -393,12 +449,13 @@ function preparePreview(callback, scale = 1, offset = 0) {
stack.obj.rotate(widgets[0].belt); stack.obj.rotate(widgets[0].belt);
} }
api.hide.alert(alert); api.hide.alert(alert);
segtimes[`${segNumber}_draw`] = Date.now() - startTime; segtimes[`${segNumber} draw`] = Date.now() - startTime;
} }
// print stats // print stats
segtimes.total = Date.now() - mark; segtimes.total = Date.now() - mark;
console.log(segtimes); console.log(segtimes);
api.visuals.update_stats(segtimes);
event.emit('print', pMode); event.emit('print', pMode);
event.emit('preview.end', pMode); event.emit('preview.end', pMode);
@ -427,6 +484,10 @@ function preparePreview(callback, scale = 1, offset = 0) {
}); });
} }
/**
* Prepare animation (requires SharedArrayBuffer support).
* Checks for browser support and emits function.animate event.
*/
function prepareAnimation() { function prepareAnimation() {
if (!window.SharedArrayBuffer) { if (!window.SharedArrayBuffer) {
api.alerts.show("The security context of this"); api.alerts.show("The security context of this");
@ -437,6 +498,12 @@ function prepareAnimation() {
api.event.emit("function.animate", {mode: api.conf.get().mode}); api.event.emit("function.animate", {mode: api.conf.get().mode});
} }
/**
* Prepare and trigger export.
* Auto-runs preview first if not already complete.
* Delegates to exportFile() for mode-specific export.
* @param {...*} args - Arguments passed through to exportFile()
*/
function prepareExport() { function prepareExport() {
const settings = api.conf.get(); const settings = api.conf.get();
const argsave = arguments; const argsave = arguments;
@ -449,12 +516,23 @@ function prepareExport() {
exportFile(...argsave); exportFile(...argsave);
} }
/**
* Cancel running worker operation by restarting the worker.
*/
function cancelWorker() { function cancelWorker() {
if (client.isBusy()) { if (client.isBusy()) {
client.restart(); client.restart();
} }
} }
/**
* Parse and visualize gcode or other toolpath code.
* Sends code to worker for parsing, renders result as 3D preview.
* Emits code.load and code.loaded events.
*
* @param {string} code - Gcode or toolpath text
* @param {string} type - Code type identifier (gcode, etc.)
*/
function parseCode(code, type) { function parseCode(code, type) {
const { conf, event, show, stacks, widgets, view } = api; const { conf, event, show, stacks, widgets, view } = api;
const settings = conf.get(); const settings = conf.get();
@ -479,6 +557,10 @@ function parseCode(code, type) {
}); });
} }
/**
* Clear operation completion tracking.
* Resets complete state so operations can run again.
*/
function clear_progress() { function clear_progress() {
complete = {}; complete = {};
} }

24
src/kiri/app/groups.js Normal file
View file

@ -0,0 +1,24 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { selection } from './selected.js';
import { Widget } from './widget.js';
/**
* Merge selected widgets into a group.
* Grouped widgets move together as a unit.
*/
function groupMerge() {
Widget.Groups.merge(selection.widgets(true));
}
/**
* Split grouped widgets back into individual widgets.
*/
function groupSplit() {
Widget.Groups.split(selection.widgets(false));
}
export const group = {
merge: groupMerge,
split: groupSplit,
};

36
src/kiri/app/help.js Normal file
View file

@ -0,0 +1,36 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { $ } from '../../moto/webui.js';
import { api } from './api.js';
import { modal } from './modal.js';
import { version } from '../../moto/license.js';
const WIN = self.window;
/**
* Show local help dialog.
*/
function showHelp() {
showHelpFile(`local`,() => {});
}
/**
* Show help dialog or open external docs.
* @param {string} local - If truthy, shows local help modal; otherwise opens docs.grid.space
* @param {function} then - Callback after help shown
*/
function showHelpFile(local,then) {
if (!local) {
WIN.open("//docs.grid.space/", "_help");
return;
}
const LANG = api.language.current;
$('kiri-version').innerHTML = `${LANG.version} ${version}`;
modal.show('help');
api.event.emit('help.show', local);
}
export const help = {
show: showHelp,
file: showHelpFile
};

103
src/kiri/app/image.js Normal file
View file

@ -0,0 +1,103 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { $ } from '../../moto/webui.js';
import { api } from './api.js';
import { newWidget } from './widget.js';
import { platform } from './platform.js';
import { settings } from './conf/manager.js';
/**
* Show image import dialog with conversion options.
* Prompts for blur, inversion, base size, and border settings.
* Large images (>2.5MB) show a warning before proceeding.
* @param {ArrayBuffer} image - PNG image data
* @param {string} name - Filename
* @param {boolean} [force] - Skip size warning if true
*/
function loadImageDialog(image, name, force) {
if (!force && image.byteLength > 2500000) {
return api.uc.confirm("Large images may fail to import<br>Consider resizing under 1000 x 1000<br>Proceed with import?").then(ok => {
if (ok) {
loadImageDialog(image, name, true);
}
});
}
const opt = {pre: [
"<div class='f-col a-center'>",
" <h3>Image Conversion</h3>",
" <p class='t-just' style='width:300px;line-height:1.5em'>",
" This will create a 3D model from a 2D PNG image. Photos must",
" be blurred to be usable. Values from 0=off to 50=high are suggested.",
" Higher values incur more processing time.",
" </p>",
" <div class='f-row t-right'><table>",
" <tr><th>blur value</th><td><input id='png-blur' value='0' size='3'></td>",
" <th>&nbsp;invert image</th><td><input id='png-inv' type='checkbox'></td></tr>",
" <tr><th>base size</th><td><input id='png-base' value='0' size='3'></td>",
" <th>&nbsp;invert alpha</th><td><input id='alpha-inv' type='checkbox'></td></tr>",
" <tr><th>border size</th><td><input id='png-border' value='0' size='3'></td>",
" <th></th><td></td></tr>",
" </table></div>",
"</div>"
]};
api.uc.confirm(undefined, {convert:true, cancel:false}, undefined, opt).then((ok) => {
if (ok) {
loadImage(image, {
file: name,
blur: parseInt($('png-blur').value) || 0,
base: parseInt($('png-base').value) || 0,
border: parseInt($('png-border').value) || 0,
inv_image: $('png-inv').checked,
inv_alpha: $('alpha-inv').checked
});
}
});
}
/**
* Convert PNG image to 3D mesh using worker.
* Creates height map from pixel brightness and generates vertices.
* @param {ArrayBuffer} image - PNG image data
* @param {object} [opt={}] - Options: file, blur, base, border, inv_image, inv_alpha
*/
function loadImage(image, opt = {}) {
const info = Object.assign({settings: settings.get(), png:image}, opt);
api.client.image2mesh(info, progress => {
api.show.progress(progress, "converting");
}, vertices => {
api.show.progress(0);
const widget = newWidget().loadVertices(vertices);
widget.meta.file = opt.file;
platform.add(widget);
});
}
/**
* Convert any image format to PNG before loading.
* Uses canvas to convert image blob to PNG data URL.
* @param {ArrayBuffer} res - Image data in any format
* @param {string} name - Filename
*/
function loadImageConvert(res, name) {
let url = URL.createObjectURL(new Blob([res]));
$('mod-any').innerHTML = `<img id="xsrc" src="${url}"><canvas id="xdst"></canvas>`;
let img = $('xsrc');
let can = $('xdst');
img.onload = () => {
can.width = img.width;
can.height = img.height;
let ctx = can.getContext('2d');
ctx.drawImage(img, 0, 0);
fetch(can.toDataURL()).then(r => r.arrayBuffer()).then(data => {
loadImageDialog(data, name);
});
};
}
export const image = {
dialog: loadImageDialog,
convert: loadImageConvert
};

134
src/kiri/app/init/bind.js Normal file
View file

@ -0,0 +1,134 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { $ } from '../../../moto/webui.js';
export function bind() {
return {
alert: {
dialog: $('alert-area'),
text: $('alert-text')
},
func: {
slice: $('act-slice'),
preview: $('act-preview'),
animate: $('act-animate'),
export: $('act-export')
},
label: {
slice: $('label-slice'),
preview: $('label-preview'),
animate: $('label-animate'),
export: $('label-export'),
},
acct: {
help: $('app-help'),
don8: $('app-don8'),
mesh: $('app-mesh'),
export: $('app-export')
},
dev: {
header: $('dev-header'),
search: $('dev-search'),
filter: $('dev-filter')
},
mesh: {
name: $('mesh-name'),
points: $('mesh-points'),
faces: $('mesh-faces'),
},
stats: {
fps: $('fps'),
rms: $('rms'),
div: $('stats'),
rnfo: $('rnfo'),
},
load: $('load-file'),
speeds: $('speeds'),
speedbar: $('speedbar'),
context: $('context-menu'),
// ltsetup: $('lt-setup'),
// ltfile: $('lt-file'),
// ltview: $('lt-view'),
// ltact: $('lt-start'),
// edit: $('lt-tools'),
nozzle: $('menu-nozzle'),
modal: $('modal'),
modalBox: $('modal-box'),
modals: {
help: $('mod-help'),
setup: $('mod-setup'),
prefs: $('mod-prefs'),
files: $('mod-files'),
saves: $('mod-saves'),
tools: $('mod-tools'),
xany: $('mod-x-any'),
xsla: $('mod-x-sla'),
xlaser: $('mod-x-laser'),
don8: $('mod-don8'),
any: $('mod-any'),
},
catalog: {
body: $('catalogBody'),
list: $('catalogList'),
},
devices: {
div: $('devices'),
add: $('device-add'),
delete: $('device-del'),
export: $('device-exp'),
rename: $('device-ren'),
save: $('device-save'),
},
setMenu: $('set-menu'),
settings: $('settings'),
settingsBody: $('settingsBody'),
settingsList: $('settingsList'),
settingsName: $('settingsName'),
settingsSave: $('settingsSave'),
slider: {
div: $('slider'),
hi: $('slider-hi'),
hold: $('slider-hold'),
lo: $('slider-lo'),
max: $('slider-max'),
mid: $('slider-mid'),
min: $('slider-zero'),
range: $('slider-center'),
},
loading: $('progress').style,
progress: $('progbar').style,
prostatus: $('progtxt'),
selection: $('selection'),
size: {
X: $('size_x'),
Y: $('size_y'),
Z: $('size_z'),
},
scale: {
X: $('scale_x'),
Y: $('scale_y'),
Z: $('scale_z'),
},
lock: {
X: $('lock_x'),
Y: $('lock_y'),
Z: $('lock_z'),
},
stock: $('stock'),
stockWidth: $('stock-width'),
stockDepth: $('stock-width'),
stockHeight: $('stock-width'),
};
}

546
src/kiri/app/init/input.js Normal file
View file

@ -0,0 +1,546 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { $ } from '../../../moto/webui.js';
import { api } from '../api.js';
import { bind } from './bind.js';
import { version } from '../../../moto/license.js';
import { fileOps } from '../file-ops.js';
import { init as initCAM } from '../../mode/cam/app/init-ui.js';
import { init as initDRAG } from '../../mode/drag/init-ui.js';
import { init as initFDM } from '../../mode/fdm/app/init-ui.js';
import { init as initLaser } from '../../mode/laser/init-ui.js';
import { init as initSLA } from '../../mode/sla/app/init-ui.js';
import { init as initWEDM } from '../../mode/wedm/init-ui.js';
import { init as initWJET } from '../../mode/wjet/init-ui.js';
import { interact } from '../mouse.js';
import { keyboard } from '../keyboard.js';
import { local as sdb } from '../../../data/local.js';
import { menu as menuCAM } from '../../mode/cam/app/init-menu.js';
import { menu as menuFDM } from '../../mode/fdm/app/init-menu.js';
import { menu as menuLaser } from '../../mode/laser/init-menu.js';
import { menu as menuSLA } from '../../mode/sla/app/init-menu.js';
import { modal } from '../modal.js';
import { preferences } from '../preferences.js';
import { settings as set_ctrl } from '../conf/manager.js';
import { settingsOps } from '../conf/settings.js';
import { slider } from '../slider.js';
import { space } from '../../../moto/space.js';
import { LAST, MODES, SEED, VIEWS } from '../consts.js';
import STACKS from '../stacks.js';
import '../frame.js';
let { SETUP } = api.const,
{ CAM, SLA, FDM, LASER, DRAG, WJET, WEDM } = MODES,
{ catalog, platform, selection, stats } = api,
{ input_binding, input_position, input_rotate } = selection,
DOC = self.document,
WIN = self.window,
LANG = api.language.current,
TWOD = [ LASER, DRAG, WJET, WEDM ],
TWONED = [ LASER, DRAG, WJET ],
THREED = [ FDM, CAM, SLA ],
GCODE = [ FDM, CAM, ...TWOD ],
CAM_LZR = [ CAM, ...TWOD ],
FDM_LZN = [ FDM, ...TWONED ],
NO_WEDM = [ FDM, CAM, SLA, LASER, DRAG, WJET ],
FDM_CAM = [ FDM, CAM ],
inline = true,
driven = true,
trigger = true,
units = true,
ui = api.ui,
uc = api.uc;
function settings() {
return api.conf.get();
}
function updateTool(ev) {
api.tool.update(ev);
}
function checkSeed(then) {
// skip sample object load in onshape (or any script postload)
if (!sdb[SEED]) {
sdb[SEED] = new Date().getTime();
if (!SETUP.s && api.feature.seed) {
if (SETUP.debug) {
return then();
}
platform.load_stl("/obj/cube.stl", function(vert) {
catalog.putFile("sample cube.stl", vert);
platform.update_bounds();
space.view.home();
setTimeout(() => { api.space.save(true) },500);
then();
api.help.show();
sdb[LAST] = version;
});
return true;
}
} else if (sdb[LAST] !== version) {
sdb[LAST] = version;
api.help.show();
}
then();
return false;
}
// upon restore, seed presets
api.event.emit('preset', api.conf.dbo());
// api.event.on("set.threaded", bool => setThreaded(bool));
export function onBooleanClick(el) {
// copy some ui elements to target settings
let settings = api.conf.get();
settings.device.bedBelt = ui.deviceBelt.checked && api.mode.is_fdm();
settings.device.bedRound = ui.deviceRound.checked && api.mode.is_fdm();
settings.device.originCenter = ui.deviceOrigin.checked || ui.deviceRound.checked;
settings.device.fwRetract = ui.fwRetract.checked;
// refresh vars and other ui elements
uc.refresh();
if (el === ui.camStockIndexed) {
api.view.set_arrange();
}
api.conf.update();
DOC.activeElement.blur();
api.event.emit("boolean.click");
api.devices.update_laser_state();
}
api.event.on('click.boolean', onBooleanClick);
function onButtonClick(ev) {
let target = ev.target;
while (target && target.tagName !== 'BUTTON') {
target = target.parentNode;
}
api.event.emit("button.click", target);
}
api.event.on('click.button', onButtonClick);
// function keys(o) {
// let key, list = [];
// for (key in o) { if (o.hasOwnProperty(key)) list.push(key) }
// return list.sort();
// }
function isBelt() {
return api.device.isBelt();
}
function isNotBelt() {
return !isBelt();
}
function onResize() {
if (WIN.innerHeight < 800) {
ui.modalBox.classList.add('mh85');
} else {
ui.modalBox.classList.remove('mh85');
}
api.view.update_slider();
}
// MAIN UI INITIALIZATION FUNCTION
export function init_input() {
let { event, conf, view, show } = api,
{ newBlank, newButton, newBoolean, newGroup, newInput } = uc,
{ newSelect, newRow, newGCode, newDiv, toInt, toFloat } = uc;
event.emit('init.one');
// restore kiri-init vars
let inits = parseInt(sdb.getItem('kiri-init') || stats.get('init') || 0) + 1;
// update version and init count
sdb.setItem('kiri-init', inits);
stats.set('init', inits);
stats.set('kiri', version);
// restore settings from last saved session
conf.restore();
let container = $('container'),
gcode = $('dev-gcode'),
tracker = $('tracker'),
controller = settings().controller;
// window resize handlers
WIN.addEventListener("resize", onResize);
event.on('resize', onResize);
// configure moto.space
space.sky.showGrid(false);
space.sky.setColor(controller.dark ? 0 : 0xffffff);
space.setAntiAlias(controller.antiAlias);
space.init(container, (delta) => {
const { lo, hi, max } = slider.getRange();
if (max === 0 || !delta) return;
if (controller.reverseZoom) delta = -delta;
let same = hi === lo;
let track = lo > 0;
let newHi = hi;
let newLo = lo;
if (delta > 0) {
newHi = Math.max(same ? 0 : lo, hi - 1);
if (track) {
newLo = Math.max(0, lo - 1);
}
} else if (delta < 0) {
newHi = Math.min(max, hi + 1);
if (track) {
newLo = Math.min(newHi, lo + 1);
}
}
if (same) {
newLo = newHi;
}
slider.setRange(newLo, newHi);
view.update_slider();
show.slices();
}, controller.ortho);
space.platform.onMove(conf.save);
space.platform.setRound(true);
space.useDefaultKeys(api.feature.on_key === undefined || api.feature.on_key_defaults);
preferences.updateDrawer();
// api augmentation with local functions
api.device.export = settingsOps.export_device;
Object.assign(ui, {
tracker: tracker,
container: container,
// static html --> ui bindings
...bind(),
/** Device Browser / Editor */
_____: newDiv({ class: "f-col t-body t-inset", addto: $('dev-config'), set:true }),
device: newGroup(LANG.dv_gr_dev, null, {group:"ddev", inline, class:"noshow"}),
_____: newGroup("workspace", null, {group:"dext", inline}),
bedWidth: newInput('X (width)', {title:LANG.dv_bedw_l, convert:toFloat, size:6, units, round:2, action:settingsOps.update_device}),
bedDepth: newInput('Y (depth)', {title:LANG.dv_bedw_l, convert:toFloat, size:6, units, round:2, action:settingsOps.update_device}),
maxHeight: newInput('Z (height)', {title:LANG.dv_bedw_l, convert:toFloat, size:6, units, round:2, action:settingsOps.update_device}),
resolutionX: newInput(LANG.dv_rezx_s, {title:LANG.dv_rezx_l, convert:toInt, size:6, modes:SLA}),
resolutionY: newInput(LANG.dv_rezy_s, {title:LANG.dv_rezy_l, convert:toInt, size:6, modes:SLA}),
_____: newDiv({ class: "f-col t-body t-inset", addto: $('dev-config'), set:true, modes:NO_WEDM }),
_____: newGroup("firmware", null, {group:"dext", inline, modes:NO_WEDM}),
fwRetract: newBoolean(LANG.dv_retr_s, onBooleanClick, {title:LANG.dv_retr_l, modes:FDM, trigger}),
deviceOrigin: newBoolean(LANG.dv_orgc_s, onBooleanClick, {title:LANG.dv_orgc_l, modes:FDM_LZN, show:() => !ui.deviceRound.checked}),
deviceRound: newBoolean(LANG.dv_bedc_s, onBooleanClick, {title:LANG.dv_bedc_l, modes:FDM, trigger, show:isNotBelt}),
deviceBelt: newBoolean(LANG.dv_belt_s, onBooleanClick, {title:LANG.dv_belt_l, modes:FDM, trigger, show:() => !ui.deviceRound.checked}),
separator: newBlank({class:"pop-sep", modes:FDM, driven}),
spindleMax: newInput(LANG.dv_spmx_s, {title:LANG.dv_spmx_l, convert:toInt, size:5, modes:CAM, trigger}),
deviceZMax: newInput(LANG.dv_zmax_s, {title:LANG.dv_zmax_l, convert:toInt, size:5, modes:FDM}),
gcodeTime: newInput(LANG.dv_time_s, {title:LANG.dv_time_l, convert:toFloat, size:5, modes:FDM}),
_____: newDiv({ class: "f-col t-body t-inset", addto: $('dev-config'), set:true, modes:FDM }),
extruder: newGroup(LANG.dv_gr_ext, null, {group:"dext", inline}),
extFilament: newInput(LANG.dv_fila_s, {title:LANG.dv_fila_l, convert:toFloat, modes:FDM}),
extNozzle: newInput(LANG.dv_nozl_s, {title:LANG.dv_nozl_l, convert:toFloat, modes:FDM}),
extOffsetX: newInput(LANG.dv_exox_s, {title:LANG.dv_exox_l, convert:toFloat, modes:FDM}),
extOffsetY: newInput(LANG.dv_exoy_s, {title:LANG.dv_exoy_l, convert:toFloat, modes:FDM}),
extPad: newBlank({class:"grow", modes:FDM}),
separator: newBlank({class:"pop-sep", modes:FDM, driven}),
extActions: newRow([
ui.extPrev = newButton(undefined, undefined, {icon:'<i class="fas fa-less-than"></i>'}),
ui.extAdd = newButton(undefined, undefined, {icon:'<i class="fas fa-plus"></i>'}),
ui.extDel = newButton(undefined, undefined, {icon:'<i class="fas fa-minus"></i>'}),
ui.extNext = newButton(undefined, undefined, {icon:'<i class="fas fa-greater-than"></i>'})
], {class:"dev-buttons ext-buttons var-row", modes:FDM}),
_____: newDiv({ class: "f-col t-body t-inset", addto: $('dev-config'), set:true, modes:CAM_LZR }),
_____: newGroup(LANG.dv_gr_out, null, {group:"dgco", inline}),
gcodeStrip: newBoolean(LANG.dv_strc_s, onBooleanClick, {title:LANG.dv_strc_l, modes:CAM}),
gcodeSpace: newBoolean(LANG.dv_tksp_s, onBooleanClick, {title:LANG.dv_tksp_l, modes:CAM_LZR}),
laserMaxPower: newInput(LANG.ou_maxp_s, {title:LANG.ou_maxp_l, modes:LASER, size:7, text:true}),
useLaser: newBoolean(LANG.dv_lazr_s, onBooleanClick, {title:LANG.dv_lazr_l, modes:CAM}),
useIndexed: newBoolean(LANG.dv_4tha_s, onBooleanClick, {title:LANG.dv_4tha_l, modes:CAM}),
gcodeFExt: newInput(LANG.dv_fext_s, {title:LANG.dv_fext_l, modes:CAM_LZR, size:7, text:true}),
gcodeEd: newGroup(LANG.dv_gr_gco, $('dg'), {group:"dgcp", inline, modes:GCODE}),
gcodeMacros: newRow([
(ui.gcodePre = newGCode(LANG.dv_head_s, {title:LANG.dv_head_l, modes:GCODE, area:gcode})).button,
(ui.gcodePost = newGCode(LANG.dv_foot_s, {title:LANG.dv_foot_l, modes:GCODE, area:gcode})).button,
(ui.gcodeLayer = newGCode(LANG.dv_layr_s, {title:LANG.dv_layr_l, modes:FDM, area:gcode})).button,
(ui.gcodeTrack = newGCode(LANG.dv_prog_s, {title:LANG.dv_prog_l, modes:FDM, area:gcode})).button,
(ui.gcodeFan = newGCode(LANG.dv_fanp_s, {title:LANG.dv_fanp_l, modes:FDM, area:gcode})).button,
(ui.gcodeFeature = newGCode(LANG.dv_feat_s, {title:LANG.dv_feat_l, modes:FDM, area:gcode})).button,
(ui.gcodeLaserOn = newGCode(LANG.dv_lzon_s, {title:LANG.dv_lzon_l, modes:LASER, area:gcode})).button,
(ui.gcodeLaserOff = newGCode(LANG.dv_lzof_s, {title:LANG.dv_lzof_l, modes:LASER, area:gcode})).button,
(ui.gcodeWaterOn = newGCode(LANG.dv_waon_s, {title:LANG.dv_waon_l, modes:WJET, area:gcode})).button,
(ui.gcodeWaterOff = newGCode(LANG.dv_waof_s, {title:LANG.dv_waof_l, modes:WJET, area:gcode})).button,
(ui.gcodeKnifeDn = newGCode(LANG.dv_dkon_s, {title:LANG.dv_dkon_l, modes:DRAG, area:gcode})).button,
(ui.gcodeKnifeUp = newGCode(LANG.dv_dkof_s, {title:LANG.dv_dkof_l, modes:DRAG, area:gcode})).button,
(ui.gcodeChange = newGCode(LANG.dv_tool_s, {title:LANG.dv_tool_l, modes:FDM_CAM, area:gcode})).button,
(ui.gcodeDwell = newGCode(LANG.dv_dwll_s, {title:LANG.dv_dwll_l, modes:CAM, area:gcode})).button,
(ui.gcodeSpindle = newGCode(LANG.dv_sspd_s, {title:LANG.dv_sspd_l, modes:CAM, area:gcode, show:() => ui.spindleMax.value > 0})).button,
(ui.gcodeResetA = newGCode(LANG.dv_resa_s, {title:LANG.dv_resa_l, modes:CAM, area:gcode, show:() => ui.useIndexed.checked})).button
], {class:"ext-buttons f-row gcode-macros"}),
/** Preferences Menu */
_____: newGroup(LANG.op_menu, $('prefs-gen1'), {inline}),
antiAlias: newBoolean(LANG.op_anta_s, preferences.booleanSave, {title:LANG.op_anta_l}),
reverseZoom: newBoolean(LANG.op_invr_s, preferences.booleanSave, {title:LANG.op_invr_l}),
ortho: newBoolean(LANG.op_orth_s, preferences.booleanSave, {title:LANG.op_orth_l}),
dark: newBoolean(LANG.op_dark_s, preferences.booleanSave, {title:LANG.op_dark_l}),
drawer: newBoolean('slide out', preferences.booleanSave, {title:'slide out settings drawer'}),
scrolls: newBoolean('scrollbars', preferences.booleanSave, {title:'show scrollbars'}),
devel: newBoolean(LANG.op_devl_s, preferences.booleanSave, {title:LANG.op_devl_l}),
_____: newGroup(LANG.op_disp, $('prefs-gen2'), {inline}),
showOrigin: newBoolean(LANG.op_shor_s, preferences.booleanSave, {title:LANG.op_shor_l}),
showRulers: newBoolean(LANG.op_shru_s, preferences.booleanSave, {title:LANG.op_shru_l}),
showSpeeds: newBoolean(LANG.op_sped_s, preferences.speedSave, {title:LANG.op_sped_l}),
shiny: newBoolean(LANG.op_shny_s, preferences.booleanSave, {title:LANG.op_shny_l, modes:FDM}),
lineType: newSelect(LANG.op_line_s, {title: LANG.op_line_l, action: preferences.lineTypeSave, modes:FDM}, "linetype"),
manifold: newBoolean(LANG.op_mani_s, preferences.booleanSave, {title: LANG.op_mani_l, modes:CAM}, "manifold"),
animesh: newSelect(LANG.op_anim_s, {title: LANG.op_anim_l, action: preferences.aniMeshSave, modes:CAM}, "animesh"),
units: newSelect(LANG.op_unit_s, {title: LANG.op_unit_l, action: preferences.unitsSave, modes:CAM}, "units"),
edgeangle: newInput(LANG.op_spoa_s, {title:LANG.op_spoa_l, convert:toFloat, size:3}),
_____: newGroup(LANG.lo_menu, $('prefs-lay'), {inline}),
autoSave: newBoolean(LANG.op_save_s, preferences.booleanSave, {title:LANG.op_save_l}),
autoLayout: newBoolean(LANG.op_auto_s, preferences.booleanSave, {title:LANG.op_auto_l}),
freeLayout: newBoolean(LANG.op_free_s, preferences.booleanSave, {title:LANG.op_free_l}),
spaceRandoX: newBoolean(LANG.op_spcx_s, preferences.booleanSave, {title:LANG.op_spcx_l, show:isBelt}),
spaceLayout: newInput(LANG.op_spcr_s, {title:LANG.op_spcr_l, convert:toFloat, size:3, units}),
_____: newGroup(LANG.xp_menu, $('prefs-xpo'), {inline: true}),
exportPreview: newBoolean(LANG.op_excp_s, preferences.booleanSave, {title:LANG.op_excp_l}),
exportOcto: newBoolean(LANG.op_exop_s, preferences.booleanSave, {title:LANG.op_exop_l}),
exportThumb: newBoolean(LANG.op_exth_s, preferences.booleanSave, {title:LANG.op_exth_l, modes:FDM}),
_____: newGroup(LANG.pt_menu, $('prefs-prt'), {inline}),
detail: newSelect(LANG.pt_qual_s, {title: LANG.pt_qual_l, action: preferences.detailSave}, "detail"),
healMesh: newBoolean(LANG.pt_heal_s, preferences.booleanSave, {title: LANG.pt_heal_l}),
// threaded: newBoolean(LANG.pt_thrd_s, preferences.booleanSave, {title: LANG.pt_thrd_l, modes:THREED}),
assembly: newBoolean(LANG.pt_assy_s, preferences.booleanSave, {title: LANG.pt_assy_l, modes:THREED}),
webGPU: newBoolean(LANG.pt_wgpu_s, preferences.booleanSave, {title: LANG.pt_wgpu_l, modes:THREED}),
prefadd: uc.checkpoint($('prefs-add')),
/** FDM Settings */
...menuFDM(),
/** CAM Settings */
...menuCAM(),
/** LASER/DRAG/WJET/WEDM cut tool Settings */
...menuLaser(),
/** SLA SETTINGS */
...menuSLA(),
layers: uc.setGroup($("layers")),
});
// run client mode initializations right after UI is bound
// MUST run after main ui menu inits since they rely on passed state
// that createPopOps disrupts (should be refactored)
initCAM();
initDRAG();
initFDM();
initLaser();
initSLA();
initWEDM();
initWJET();
// override old style settings two-button menu
ui.settingsSave.onclick = () => {
settingsOps.settings_save(undefined, ui.settingsName.value);
};
// initialize and expose modal to API
api.modal = modal.init({
ui: {
modal: ui.modal,
modals: ui.modals
},
onShow: (which) => {
api.event.emit('modal.show', which);
},
onHide: () => {
api.event.emit('modal.hide');
}
});
// initialize and expose slider to API
api.slider = slider.init({
ui,
tracker: tracker,
mobile: space.info.mob,
onLayerChange: (hi, lo) => {
// Visualization is handled by the code that calls setRange()
// Don't call api.show.layer here as it creates circular dependency
},
onStackUpdate: (lo, hi) => {
STACKS.setRange(lo, hi);
},
onSceneUpdate: () => {
space.scene.active();
}
});
// initialize and expose keyboard to API
api.keyboard = keyboard.init({
api,
platform,
selection,
slider,
space,
catalog,
sdb,
uc,
setCtrl: set_ctrl,
VIEWS,
DOC,
WIN,
rotateInputSelection: input_rotate,
settingsLoad: settingsOps.settings_load
});
// add mobile class to body if needed
if (space.info.mob) {
DOC.body.classList.add('mobile');
}
ui.load.onchange = (event) => {
api.platform.load_files(event.target.files);
ui.load.value = ''; // reset so you can re-import the same filee
};
// store layer preferences
api.event.on('stack.show', (label) => {
let mode = api.mode.get();
let view = api.view.get();
api.conf.get().labels[`${mode}-${view}-${label}`] = true;
});
api.event.on('stack.hide', (label) => {
let mode = api.mode.get();
let view = api.view.get();
api.conf.get().labels[`${mode}-${view}-${label}`] = false;
});
// bind menu language choices
let lang_map = [
[ 'lset-en', 'en-us' ],
[ 'lset-da', 'da-dk' ],
[ 'lset-de', 'de-de' ],
[ 'lset-fr', 'fr-fr' ],
[ 'lset-pl', 'pl-pl' ],
[ 'lset-pt', 'pt-pt' ],
[ 'lset-es', 'es-es' ],
[ 'lset-zh', 'zh' ],
]
for (let [ btn, lang ] of lang_map) {
$(btn).onclick = () => {
sdb.setItem('kiri-lang', lang);
api.space.reload();
};
}
space.event.addHandlers(self, [
'dragover', fileOps.dragOverHandler,
'dragleave', fileOps.dragLeave,
'drop', fileOps.dropHandler
]);
// Setup selection transformation bindings
input_binding(ui);
$('lab-axis').onclick = () => {
ui.lock.X.checked =
ui.lock.Y.checked =
ui.lock.Z.checked = !(
ui.lock.X.checked ||
ui.lock.Y.checked ||
ui.lock.Z.checked
);
};
$('scale-reset').onclick = $('lab-scale').onclick = () => {
selection.scale(1 / ui.scale.X.was, 1 / ui.scale.Y.was, 1 / ui.scale.Z.was);
ui.scale.X.value = ui.scale.Y.value = ui.scale.Z.value =
ui.scale.X.was = ui.scale.Y.was = ui.scale.Z.was = 1;
};
$('app-xpnd').onclick = () => {
try {
DOC.body.requestFullscreen();
} catch (e) {
event.emit('resize');
space.event.onResize();
}
};
if (!DOC.body.requestFullscreen) {
$('app-xpnd').style.display = 'none';
}
uc.onBlur([
ui.toolName,
ui.toolNum,
ui.toolFluteDiam,
ui.toolFluteLen,
ui.toolShaftDiam,
ui.toolShaftLen,
ui.toolTaperTip,
], updateTool);
ui.toolMetric.onclick = updateTool;
ui.toolType.onchange = updateTool;
// default show gcode pre
ui.gcodePre.button.click();
api.platform.update_size();
// initialize interaction controller
interact.init({
api,
space,
platform,
selection,
ui,
settings,
VIEWS,
onHover: (data) => {
api.event.emit('mouse.hover', data);
},
onHoverDown: (data) => {
api.event.emit('mouse.hover.down', data);
},
onHoverUp: (data) => {
api.event.emit('mouse.hover.up', data);
},
onDragDone: (offset) => {
api.event.emit('mouse.drag.done', offset);
},
onSelectionDrag: (delta) => {
api.event.emit('selection.drag', delta);
}
});
// expose interact to API
api.interact = interact;
// prevent safari from exiting full screen mode
DOC.onkeydown = function (evt) {
if (evt.code == 27) evt.preventDefault()
}
// block standard browser context menu
DOC.oncontextmenu = (event) => {
let et = event.target;
if (et.tagName === 'CANVAS' || et.id === 'context-menu' || et.classList.contains('draggable')) {
event.preventDefault();
event.stopPropagation();
return false;
}
};
return new Promise(resolve => {
api.space.restore(() => checkSeed(resolve));
});
};

69
src/kiri/app/init/lang.js Normal file
View file

@ -0,0 +1,69 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { api } from '../api.js';
import { local as sdb } from '../../../data/local.js';
const { SETUP } = api.const;
const DOC = self.document;
const stats = api.stats;
// update static html elements with language overrides
function lang_rewrite() {
const LANG = api.language.current;
// lk attribute causes inner text to be replaced with lang value
for (let el of [...DOC.querySelectorAll("[lk]")]) {
let key = el.getAttribute('lk');
let val = LANG[key];
if (val) {
el.innerText = val;
} else {
console.log({missing_ln: key});
}
}
// lkt attribute causes a title attribute to be set from lang value
for (let el of [...DOC.querySelectorAll("[lkt]")]) {
let key = el.getAttribute('lkt');
let val = LANG[key];
if (val) {
el.setAttribute("title", val);
} else {
console.log({missing_ln: key});
}
}
}
// init lang must happen before all other init functions
export function init_lang() {
return new Promise(resolve => {
// if a language needs to load, the script is injected and loaded
// first. once this loads, or doesn't, the initialization begins
let lang = SETUP.ln ? SETUP.ln[0] : sdb.getItem('kiri-lang') || api.language.get();
api.event.emit('init.lang', lang);
// inject language script if not english
if (lang && lang !== 'en' && lang !== 'en-us') {
let map = api.language.map(lang);
let scr = DOC.createElement('script');
// scr.setAttribute('defer',true);
scr.setAttribute('src',`/kiri/lang/${map}.js`);
(DOC.body || DOC.head).appendChild(scr);
stats.set('ll',lang);
scr.onload = function() {
api.language.set(map);
lang_rewrite();
resolve();
};
scr.onerror = function(err) {
console.log({language_load_error: err, lang})
api.language.set();
lang_rewrite();
resolve();
}
} else {
// set to browser default which will be overridden
// by any future script loads (above)
api.language.set();
lang_rewrite();
resolve();
}
});
}

324
src/kiri/app/init/sync.js Normal file
View file

@ -0,0 +1,324 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { $ } from '../../../moto/webui.js';
import { api } from '../api.js';
import { beta, version } from '../../../moto/license.js';
import { fileOps } from '../file-ops.js';
import { local as sdb } from '../../../data/local.js';
import { preferences } from '../preferences.js';
import { settings as set_ctrl } from '../conf/manager.js';
import { settingsOps } from '../conf/settings.js';
import { space } from '../../../moto/space.js';
import { VIEWS } from '../consts.js';
import * as view_tools from '../face-tool.js';
const { SETUP, LOCAL } = api.const;
const { catalog, client, platform, selection, stats, ui } = api;
const DOC = self.document;
const WIN = self.window;
const LANG = api.language.current;
const STARTMODE = SETUP.sm && SETUP.sm.length === 1 ? SETUP.sm[0] : null;
// SECOND STAGE INIT AFTER UI RESTORED
export async function init_sync() {
const proto = location.protocol;
api.event.emit('init.two');
// load script extensions
if (SETUP.s) SETUP.s.forEach(function(lib) {
let scr = DOC.createElement('script');
scr.setAttribute('async', true);
scr.setAttribute('defer', true);
scr.setAttribute('src',`/code/${lib}.js`);
DOC.body.appendChild(scr);
stats.add('load_'+lib);
api.event.emit('load.lib', lib);
});
// override stored settings
if (SETUP.v) SETUP.v.forEach(function(kv) {
kv = kv.split('=');
sdb.setItem(kv[0],kv[1]);
});
// import octoprint settings
if (SETUP.ophost) {
let ohost = api.const.OCTO = {
host: SETUP.ophost[0],
apik: SETUP.opkey ? SETUP.opkey[0] : ''
};
sdb['octo-host'] = ohost.host;
sdb['octo-apik'] = ohost.apik;
console.log({octoprint:ohost});
}
// One-time migration: Initialize model.opacity from existing ghosting state
if (api.local.getFloat('model.opacity') === null) {
const wireframeOpacity = api.local.getFloat('model.wireframe.opacity');
const wireframe = api.local.getBoolean('model.wireframe', false);
// If ghosting was enabled (wireframe off, opacity < 1), preserve it
if (!wireframe && wireframeOpacity !== null && wireframeOpacity < 1) {
api.local.set('model.opacity', wireframeOpacity);
} else {
api.local.set('model.opacity', 1.0);
}
}
// load workspace from url
if (SETUP.wrk) {
set_ctrl.import_url(`${proto}//${SETUP.wrk[0]}`, false);
}
// load an object from url
if (SETUP.load) {
console.log({load:SETUP});
api.platform.load_url(`${proto}//${SETUP.load[0]}`);
}
// bind this to UI so main can call it on settings import
ui.sync = ui_sync;
ui_sync();
// clear alerts as they build up
setInterval(api.event.alerts, 1000);
// add hide-alerts-on-alert-click
ui.alert.dialog.onclick = function() {
api.event.alerts(true);
};
if (!SETUP.s) console.log(`kiri | init main | ${version}`);
// send init-done event
api.event.emit('init-done', stats);
// show gdpr if it's never been seen and we're not iframed
const isLocal = LOCAL || WIN.location.host.split(':')[0] === 'localhost';
if (!sdb.gdpr && WIN.self === WIN.top && !SETUP.debug && !isLocal) {
$('gdpr').style.display = 'flex';
}
// warn of degraded functionality when SharedArrayBuffers are missing
if (api.feature.work_alerts && !window.SharedArrayBuffer) {
api.alerts.show("The security context of this", 10);
api.alerts.show("Window blocks important functionality.", 10);
api.alerts.show("Try a Chromium-base Browser instead", 10);
}
// add keyboard focus handler (must use for iframes)
WIN.addEventListener('load', function () {
WIN.focus();
DOC.body.addEventListener('click', function() {
WIN.focus();
},false);
});
// Setup navigation button bindings
setup_keybd_nav();
// show topline separator when iframed
try { if (WIN.self !== WIN.top) $('top-sep').style.display = 'flex' } catch (e) { console.log(e) }
// warn users they are running a beta release
if (beta && beta > 0 && sdb.kiri_beta != beta) {
api.show.alert('<b style="color:red">caution:</b> beta code ahead', 10);
sdb.kiri_beta = beta;
}
// warn users they are using a development server
let devwarn = sdb.kiri_dev;
if (location.host === 'dev.grid.space' && devwarn !== version) {
api.alerts.show('this is a development server', 10);
api.alerts.show('use <a href="https://grid.space/kiri">grid.space</a> for production', 10);
sdb.kiri_dev = version;
}
// hide url params but preserve version root (when present)
let wlp = WIN.location.pathname;
let kio = wlp.indexOf('/kiri/');
if (kio >= 0) {
history.replaceState({}, '', wlp.substring(0,kio + 6));
}
// lift curtain
$('curtain').style.display = 'none';
}
function ui_sync() {
const current = api.conf.get();
const control = current.controller;
if (!control.devel) {
// TODO: hide thin type 3 during development
api.const.LISTS.thin.length = 3;
}
platform.deselect();
catalog.addFileListener(fileOps.updateCatalog);
space.view.setZoom(control.reverseZoom, control.zoomSpeed);
space.platform.setGridZOff(undefined);
space.platform.setZOff(0.05);
space.view.setProjection(control.ortho ? 'orthographic' : 'perspective');
// restore UI state from settings
ui.antiAlias.checked = control.antiAlias;
ui.assembly.checked = control.assembly;
ui.autoLayout.checked = control.autoLayout;
ui.autoSave.checked = control.autoSave;
ui.devel.checked = control.devel;
ui.freeLayout.checked = control.freeLayout;
ui.healMesh.checked = control.healMesh;
ui.manifold.checked = control.manifold;
ui.ortho.checked = control.ortho;
ui.reverseZoom.checked = control.reverseZoom;
ui.showOrigin.checked = control.showOrigin;
ui.showRulers.checked = control.showRulers;
ui.showSpeeds.checked = control.showSpeeds;
ui.spaceRandoX.checked = control.spaceRandoX;
// ui.threaded.checked = setThreaded(control.threaded);
ui.webGPU.checked = control.webGPU;
preferences.setThreaded(true);
preferences.lineTypeSave();
preferences.detailSave();
api.visuals.update_stats();
// optional set-and-lock mode (hides mode menu)
let SETMODE = SETUP.mode ? SETUP.mode[0] : null;
// optional set-and-lock device (hides device menu)
let DEVNAME = SETUP.dev ? SETUP.dev[0] : null;
// setup default mode and enable mode locking, if set
api.mode.set(SETMODE || STARTMODE || current.mode, SETMODE);
// fill device list
api.devices.refresh();
// update ui fields from settings
api.conf.update_fields();
// default to ARRANGE view mode
api.view.set(VIEWS.ARRANGE);
// add ability to override (todo: restore?)
// api.show.controls(api.feature.controls);
// update everything dependent on the platform size
platform.update_size();
// load wasm if indicated
client.wasm(control.assembly === true);
}
function setup_keybd_nav() {
// bind interface action elements
ui.acct.help.onclick = (ev) => { ev.stopPropagation(); api.help.show() };
ui.acct.don8.onclick = (ev) => { ev.stopPropagation(); api.modal.show('don8') };
ui.acct.mesh.onclick = (ev) => { ev.stopPropagation(); WIN.location = "/mesh" };
ui.acct.export.onclick = (ev) => { ev.stopPropagation(); settingsOps.export_profile() };
ui.acct.export.title = LANG.acct_xpo;
ui.func.slice.onclick = (ev) => { ev.stopPropagation(); api.function.slice() };
ui.func.preview.onclick = (ev) => { ev.stopPropagation(); api.function.print() };
ui.func.animate.onclick = (ev) => { ev.stopPropagation(); api.function.animate() };
ui.func.export.onclick = (ev) => { ev.stopPropagation(); api.function.export() };
// prevent modal input from propagating to parents
ui.modalBox.onclick = (ev) => { ev.stopPropagation() };
$('export-support-a').onclick = (ev) => { ev.stopPropagation(); api.modal.show('don8') };
$('mode-device').onclick = api.show.devices;
$('mode-profile').onclick = settingsOps.settings_load;
$('mode-fdm').onclick = () => api.mode.set('FDM');
$('mode-cam').onclick = () => api.mode.set('CAM');
$('mode-sla').onclick = () => api.mode.set('SLA');
$('mode-laser').onclick = () => api.mode.set('LASER');
$('mode-drag').onclick = () => api.mode.set('DRAG');
$('mode-wjet').onclick = () => api.mode.set('WJET');
$('mode-wedm').onclick = () => api.mode.set('WEDM');
$('set-device').onclick = (ev) => { ev.stopPropagation(); api.show.devices() };
$('set-profs').onclick = (ev) => { ev.stopPropagation(); api.conf.show() };
$('set-tools').onclick = (ev) => { ev.stopPropagation(); api.show.tools() };
$('set-prefs').onclick = (ev) => { ev.stopPropagation(); api.modal.show('prefs') };
$('file-new').onclick = (ev) => { ev.stopPropagation(); settingsOps.new_workspace() };
$('file-recent').onclick = () => { api.modal.show('files') };
$('file-import').onclick = (ev) => { api.event.import(ev); };
$('view-arrange').onclick = api.platform.layout;
$('view-top').onclick = space.view.top;
$('view-home').onclick = space.view.home;
$('view-front').onclick = space.view.front;
$('view-back').onclick = space.view.back;
$('view-left').onclick = space.view.left;
$('view-right').onclick = space.view.right;
$('unrotate').onclick = () => {
api.widgets.for(w => w.unrotate());
selection.update_info();
};
// attach button handlers to support targets
for (let btn of ["don8pt","don8gh","don8pp"]) {
$(btn).onclick = (ev) => {
window.open(ev.target.children[0].href);
}
}
// rotation buttons
let d = (Math.PI / 180);
$('rot_x_lt').onclick = () => { selection.rotate(-d * $('rot_x').value,0,0) };
$('rot_x_gt').onclick = () => { selection.rotate( d * $('rot_x').value,0,0) };
$('rot_y_lt').onclick = () => { selection.rotate(0,-d * $('rot_y').value,0) };
$('rot_y_gt').onclick = () => { selection.rotate(0, d * $('rot_y').value,0) };
$('rot_z_lt').onclick = () => { selection.rotate(0,0, d * $('rot_z').value) };
$('rot_z_gt').onclick = () => { selection.rotate(0,0,-d * $('rot_z').value) };
// rendering options
$('render-edges').onclick = () => { api.view.set_edges({ toggle: true }); api.conf.save() };
$('render-ghost').onclick = () => {
const opacity = api.view.is_arrange() ? 0.4 : 0.25;
api.view.set_wireframe(false);
api.visuals.set_opacity(opacity);
// Also save to old key for backwards compatibility
api.local.set('model.wireframe.opacity', opacity);
api.conf.save();
};
$('render-wire').onclick = () => {
api.view.set_wireframe(true, 0, api.space.is_dark() ? 0.25 : 0.5);
api.visuals.set_opacity(1.0);
api.conf.save();
};
$('render-solid').onclick = () => {
api.view.set_wireframe(false);
api.visuals.set_opacity(1.0);
// Also save to old key for backwards compatibility
api.local.set('model.wireframe.opacity', 1.0);
api.conf.save();
};
$('mesh-export-stl').onclick = () => { settingsOps.export_objects('stl') };
$('mesh-export-obj').onclick = () => { settingsOps.export_objects('obj') };
$('mesh-merge').onclick = selection.merge;
$('mesh-split').onclick = selection.isolateBodies;
$('context-duplicate').onclick = selection.duplicate;
$('context-mirror').onclick = selection.mirror;
$('context-layflat').onclick = view_tools.startLayFlat;
$('context-lefty').onclick = view_tools.startLeftAlign;
$('context-setfocus').onclick = () => {
view_tools.startFocus(ev => api.space.set_focus(undefined, ev.object.point));
};
$('context-contents').onclick = api.const.SPACE.view.fit;
$('view-fit').onclick = api.const.SPACE.view.fit;
$('wassup').onmouseover = () => { $('suppopp').classList.remove('hide') };
// enable modal hiding
$('mod-x').onclick = api.modal.hide;
// dismiss gdpr alert
$('gotit').onclick = () => {
$('gdpr').style.display = 'none';
sdb.gdpr = Date.now();
};
// add app name hover info
$('app-info').innerText = version;
}

View file

@ -1,31 +1,62 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */ /** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
/**
* UI component factory system.
* Creates form elements, dialogs, and manages UI state/visibility.
* Supports mode-specific visibility, unit conversion, and hierarchical grouping.
*/
import { $ } from '../../moto/webui.js'; import { $ } from '../../moto/webui.js';
import { api } from './api.js'; import { api } from './api.js';
let DOC = self.document, let DOC = self.document,
/** Callback for input changes */
inputAction = null, inputAction = null,
/** Previous addTo value for nesting */
lastAddTo = null, lastAddTo = null,
/** Current group array */
lastGroup = null, lastGroup = null,
/** Last created div container */
lastDiv = null, lastDiv = null,
/** Current container to add elements to */
addTo = null, addTo = null,
/** Alternative binding target for input actions */
bindTo = null, bindTo = null,
/** Map of group name to array of elements */
groups = {}, groups = {},
/** Sticky state prevents auto-hide on blur */
groupSticky = false, groupSticky = false,
/** Current group name */
groupName = undefined, groupName = undefined,
heads = {}, // hideable group heads (clickable label) /** Collapsible group headers (clickable label) */
hidden = {}, // hidden groups (by name) heads = {},
/** Hidden groups by name */
hidden = {},
/** Elements with mode visibility rules */
hasModes = [], hasModes = [],
/** Elements with unit conversion setters */
setters = [], setters = [],
/** Last mode set for visibility filtering */
lastMode = null, lastMode = null,
/** Last expert mode state */
lastExpert = true, lastExpert = true,
/** Prefix for element IDs */
prefix = "tab", prefix = "tab",
/** Unit scale multiplier for conversions */
units = 1, units = 1,
/** Last changed input element */
lastChange = null, lastChange = null,
/** Last clicked button */
lastBtn = null, lastBtn = null,
/** Last clicked text element */
lastTxt = null, lastTxt = null,
/** Last shown popup */
lastPop = null; lastPop = null;
/**
* UI component factory and utilities.
* Provides chainable builder pattern and component creation functions.
*/
export const UI = { export const UI = {
prefix: function(pre) { prefix = pre; return UI }, prefix: function(pre) { prefix = pre; return UI },
inputAction: function(fn) { inputAction = fn; return UI }, inputAction: function(fn) { inputAction = fn; return UI },
@ -84,6 +115,10 @@ export const UI = {
} }
}; };
/**
* Set hidden group map and refresh visibility.
* @param {object} map - Map of group name to hidden boolean
*/
function setHidden(map) { function setHidden(map) {
hidden = map; hidden = map;
refresh(); refresh();
@ -92,6 +127,11 @@ function setHidden(map) {
} }
} }
/**
* Attach blur event listener to element(s).
* @param {HTMLElement|Array<HTMLElement>} obj - Element or array of elements
* @param {function} fn - Blur handler function
*/
function onBlur(obj, fn) { function onBlur(obj, fn) {
if (Array.isArray(obj)) { if (Array.isArray(obj)) {
for (let o of obj) onBlur(o, fn); for (let o of obj) onBlur(o, fn);
@ -100,14 +140,34 @@ function onBlur(obj, fn) {
obj.addEventListener('blur', fn); obj.addEventListener('blur', fn);
} }
/**
* Show alert dialog with OK button.
* @param {string} message - Alert message
* @returns {Promise<boolean>} Resolves when OK clicked
*/
function alert(message) { function alert(message) {
return confirm(message, {ok:true}); return confirm(message, {ok:true});
} }
/**
* Show prompt dialog with text input.
* @param {string} message - Prompt message
* @param {string} value - Default input value
* @returns {Promise<string>} Resolves with entered text or undefined if cancelled
*/
function prompt(message, value) { function prompt(message, value) {
return confirm(message, {ok:true, cancel:undefined}, value); return confirm(message, {ok:true, cancel:undefined}, value);
} }
/**
* Show modal dialog with custom buttons and optional input.
* Blocks keyboard events while open.
* @param {string} message - Dialog message
* @param {object} buttons - Button labels mapped to return values (e.g., {yes: true, no: false})
* @param {string|Array<string>} [input] - Optional input field. Array creates textarea with lines.
* @param {object} [opt={}] - Options: {pre, post} for additional HTML content
* @returns {Promise} Resolves with button value or input value if provided
*/
function confirm(message, buttons, input, opt = {}) { function confirm(message, buttons, input, opt = {}) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let { feature } = api; let { feature } = api;
@ -148,9 +208,11 @@ function confirm(message, buttons, input, opt = {}) {
html.appendAll(opt.post); html.appendAll(opt.post);
} }
dialog.innerHTML = html.join(''); dialog.innerHTML = html.join('');
dialog.onclose = () => {
feature.on_key = onkey_save;
};
function done(value) { function done(value) {
dialog.close(); dialog.close();
feature.on_key = onkey_save;
if (value !== undefined) { if (value !== undefined) {
setTimeout(() => { resolve(value) }, 150); setTimeout(() => { resolve(value) }, 150);
} }
@ -307,6 +369,12 @@ function addCollapsableElement(parent, options = {}) {
return row; return row;
} }
/**
* Create a value bounding function.
* @param {number} low - Minimum value
* @param {number} high - Maximum value
* @returns {function} Function that clamps value to [low, high] range
*/
function bound(low,high) { function bound(low,high) {
return function(v) { return function(v) {
if (isNaN(v)) return low; if (isNaN(v)) return low;
@ -314,6 +382,11 @@ function bound(low,high) {
}; };
} }
/**
* Convert input value to integer.
* Bound to input element as `this`. Applies bounds and unit conversion if configured.
* @returns {number} Integer value
*/
function toInt() { function toInt() {
let nv = this.value !== '' ? parseInt(this.value) : null; let nv = this.value !== '' ? parseInt(this.value) : null;
if (isNaN(nv)) nv = 0; if (isNaN(nv)) nv = 0;
@ -325,6 +398,11 @@ function toInt() {
return nv; return nv;
} }
/**
* Convert input value to float.
* Bound to input element as `this`. Applies bounds and unit conversion if configured.
* @returns {number} Float value
*/
function toFloat() { function toFloat() {
let nv = this.value !== '' ? parseFloat(this.value) : null; let nv = this.value !== '' ? parseFloat(this.value) : null;
if (nv !== null && this.bound) nv = this.bound(nv); if (nv !== null && this.bound) nv = this.bound(nv);
@ -335,6 +413,11 @@ function toFloat() {
return nv; return nv;
} }
/**
* Convert comma-separated input to float array.
* Bound to input element as `this`. Applies bounds to each value.
* @returns {Array<number>} Float array
*/
function toFloatArray() { function toFloatArray() {
let nv = this.value !== '' ? this.value.split(',').map(v => parseFloat(v)) : null; let nv = this.value !== '' ? this.value.split(',').map(v => parseFloat(v)) : null;
console.log({ toFloatArray: nv }); console.log({ toFloatArray: nv });
@ -343,6 +426,11 @@ function toFloatArray() {
return nv; return nv;
} }
/**
* Convert input value to degrees float (0-360).
* Bound to input element as `this`. Normalizes to 0-359.99 range.
* @returns {number} Degrees float
*/
function toDegsFloat(){ function toDegsFloat(){
let nv = this.value !== '' ? parseFloat(this.value) : null; let nv = this.value !== '' ? parseFloat(this.value) : null;
if (nv !== null && this.bound) nv = this.bound(nv); if (nv !== null && this.bound) nv = this.bound(nv);
@ -375,6 +463,12 @@ function newLabel(text, opt = {}) {
return label; return label;
} }
/**
* Create a read-only value display element.
* @param {number} [size=6] - Input size attribute
* @param {object} [opt={}] - Options: {class}
* @returns {HTMLInputElement} Read-only input element
*/
function newValue(size = 6, opt = {}) { function newValue(size = 6, opt = {}) {
let value = DOC.createElement('input'); let value = DOC.createElement('input');
value.setAttribute("size", size); value.setAttribute("size", size);
@ -452,6 +546,13 @@ function newDiv(opt = {}) {
return div; return div;
} }
/**
* Create a collapsible details/summary element.
* Changes addTo context to the details element until endExpand() called.
* @param {string} label - Summary label text
* @param {object} [opt={}] - Options: {class, open, modes, show, etc.}
* @returns {HTMLElement} Details element with collapse() method
*/
function newExpand(label, opt = {}) { function newExpand(label, opt = {}) {
let div = DOC.createElement('details'); let div = DOC.createElement('details');
div.setAttribute('class', opt.class || 'f-col'); div.setAttribute('class', opt.class || 'f-col');
@ -467,6 +568,11 @@ function newExpand(label, opt = {}) {
div.removeAttribute('open'); div.removeAttribute('open');
}; };
if (opt.hover) {
div.addEventListener('mouseenter', event => opt.hover('enter', event));
div.addEventListener('mouseleave', event => opt.hover('leave', event));
}
lastAddTo = addTo; lastAddTo = addTo;
addTo.appendChild(div); addTo.appendChild(div);
addTo = div; addTo = div;
@ -474,6 +580,10 @@ function newExpand(label, opt = {}) {
return div; return div;
} }
/**
* End expand context, restoring previous addTo.
* @returns {HTMLElement} Restored addTo element
*/
function endExpand() { function endExpand() {
addTo = lastAddTo; addTo = lastAddTo;
return addTo; return addTo;
@ -612,6 +722,15 @@ function newText(label, options) {
return txt; return txt;
} }
/**
* Create a labeled input field with automatic validation and unit conversion.
* Supports text input or textarea (if height > 1).
* Filters non-numeric keys unless opt.text=true.
* Triggers action on blur.
* @param {string} label - Input label text
* @param {object} [opt={}] - Options: {size, height, action, convert, bound, units, round, text, comma, disabled, title, id, trigger, hide}
* @returns {HTMLInputElement|HTMLTextAreaElement} Input element with setVisible() method
*/
function newInput(label, opt = {}) { function newInput(label, opt = {}) {
let row = newDiv(opt), let row = newDiv(opt),
hide = opt.hide, hide = opt.hide,
@ -621,6 +740,14 @@ function newInput(label, opt = {}) {
ip = height > 1 ? DOC.createElement('textarea') : DOC.createElement('input'); ip = height > 1 ? DOC.createElement('textarea') : DOC.createElement('input');
row.appendChild(newLabel(label)); row.appendChild(newLabel(label));
if (opt.selector) {
let sel = DOC.createElement('button');
sel.classList.add('z-select');
sel.addEventListener('click', event => opt.selector('click', { input: ip, event, opt }));
sel.addEventListener('mouseenter', event => opt.selector('enter', { input: ip, event, opt }));
sel.addEventListener('mouseleave', event => opt.selector('leave', { input: ip, event, opt }));
row.appendChild(sel);
}
row.appendChild(ip); row.appendChild(ip);
row.setAttribute("class", opt.class || "var-row"); row.setAttribute("class", opt.class || "var-row");
if (height > 1) { if (height > 1) {
@ -680,6 +807,9 @@ function newInput(label, opt = {}) {
if (opt.trigger) { if (opt.trigger) {
refresh(opt.trigger === 1 || opt.trigger === true); refresh(opt.trigger === 1 || opt.trigger === true);
} }
if (opt.post) {
opt.post();
}
}); });
if (opt.units) { if (opt.units) {
addUnits(ip, opt.round || 3); addUnits(ip, opt.round || 3);
@ -706,6 +836,12 @@ function addUnits(input, round) {
return input; return input;
} }
/**
* Create a labeled range slider.
* @param {string} label - Slider label text
* @param {object} options - Options: {min, max, hide, title, action, modes, etc.}
* @returns {HTMLInputElement} Range input with setVisible() method
*/
function newRange(label, options) { function newRange(label, options) {
let row = newDiv(options), let row = newDiv(options),
ip = DOC.createElement('input'), ip = DOC.createElement('input'),
@ -732,6 +868,14 @@ function newRange(label, options) {
return ip; return ip;
} }
/**
* Create a labeled select dropdown.
* Triggers action on change.
* @param {string} label - Select label text
* @param {object} [options={}] - Options: {hide, id, convert, disabled, title, action, trigger, post, modes, etc.}
* @param {Array|string} source - Option source array or source attribute value
* @returns {HTMLSelectElement} Select element with setVisible() method
*/
function newSelect(label, options = {}, source) { function newSelect(label, options = {}, source) {
let row = newDiv(options), let row = newDiv(options),
ip = DOC.createElement('select'), ip = DOC.createElement('select'),
@ -774,6 +918,14 @@ function newSelect(label, options = {}, source) {
return ip; return ip;
} }
/**
* Create a labeled checkbox.
* Triggers action on click.
* @param {string} label - Checkbox label text
* @param {function} [action=bindTo] - Click handler function
* @param {object} [opt={}] - Options: {hide, disabled, title, trigger, modes, etc.}
* @returns {HTMLInputElement} Checkbox element with setVisible() method
*/
function newBoolean(label, action = bindTo, opt = {}) { function newBoolean(label, action = bindTo, opt = {}) {
let row = newDiv(opt), let row = newDiv(opt),
ip = DOC.createElement('input'), ip = DOC.createElement('input'),
@ -828,7 +980,15 @@ function newBlank(options) {
return row; return row;
} }
// unlike other elements, does not auto-add to a row /**
* Create a button element.
* Unlike other elements, does not auto-add to a row.
* Action can be a function or event name string.
* @param {string} label - Button label text
* @param {function|string} action - Click handler or event name to emit
* @param {object} [opt={}] - Options: {class, icon, title, id, modes, etc.}
* @returns {HTMLButtonElement} Button element with mode visibility
*/
function newButton(label, action, opt = {}) { function newButton(label, action, opt = {}) {
let b = DOC.createElement('button'); let b = DOC.createElement('button');
@ -866,9 +1026,19 @@ function newButton(label, action, opt = {}) {
return b; return b;
} }
/**
* Create a row container with child elements.
* @param {Array<HTMLElement>} children - Child elements to append
* @param {object} options - Options: {class, noadd, modes, etc.}
* @returns {HTMLElement} Row element
*/
function newRow(children, options) { function newRow(children, options) {
let row = addCollapsableElement((options && options.noadd) ? null : addTo); let row = addCollapsableElement((options && options.noadd) ? null : addTo);
if (children) children.forEach(function (c) { row.appendChild(c) }); if (children) {
children.forEach((c) => {
row.appendChild(c);
});
};
addModeControls(row, options); addModeControls(row, options);
if (options && options.class) { if (options && options.class) {
options.class.split(' ').forEach(ce => { options.class.split(' ').forEach(ce => {

450
src/kiri/app/keyboard.js Normal file
View file

@ -0,0 +1,450 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
/**
* Keyboard Controller (Singleton)
* Manages keyboard event handling and shortcuts
*/
class KeyboardControl {
#api;
#platform;
#selection;
#slider;
#space;
#catalog;
#sdb;
#uc;
#setCtrl;
#VIEWS;
#DOC;
#WIN;
#rotateInputSelection;
#settingsLoad;
#initialized = false;
/**
* Initialize the keyboard controller with dependencies
* @param {Object} config
* @param {Object} config.api - API object
* @param {Object} config.platform - Platform object
* @param {Object} config.selection - Selection object
* @param {Object} config.slider - Slider controller
* @param {Object} config.space - Space object
* @param {Object} config.catalog - Catalog object
* @param {Object} config.sdb - Local storage
* @param {Object} config.uc - User confirmation utility
* @param {Object} config.setCtrl - Settings controller
* @param {Object} config.VIEWS - View constants
* @param {Document} config.DOC - Document object
* @param {Window} config.WIN - Window object
* @param {Function} config.rotateInputSelection - Rotate input callback
* @param {Function} config.settingsLoad - Settings load callback
*/
init({ api, platform, selection, slider, space, catalog, sdb, uc, setCtrl, VIEWS, DOC, WIN, rotateInputSelection, settingsLoad }) {
if (this.#initialized) {
throw new Error('Keyboard already initialized');
}
this.#api = api;
this.#platform = platform;
this.#selection = selection;
this.#slider = slider;
this.#space = space;
this.#catalog = catalog;
this.#sdb = sdb;
this.#uc = uc;
this.#setCtrl = setCtrl;
this.#VIEWS = VIEWS;
this.#DOC = DOC;
this.#WIN = WIN;
this.#rotateInputSelection = rotateInputSelection;
this.#settingsLoad = settingsLoad;
this.#bindEvents();
this.#initialized = true;
return this;
}
/**
* Check if an input element has focus
* @returns {boolean}
*/
inputHasFocus() {
let active = this.#DOC.activeElement;
return active && (active.nodeName === "INPUT" || active.nodeName === "TEXTAREA");
}
/**
* Get character code at position 0
* @param {string} c - Character
* @returns {number}
*/
#cca(c) {
return c.charCodeAt(0);
}
/**
* Bind keyboard event handlers to space.event
* @private
*/
#bindEvents() {
this.#space.event.addHandlers(self, [
'keyup', this.#handleKeyUp.bind(this),
'keydown', this.#handleKeyDown.bind(this),
'keypress', this.#handleKeyPress.bind(this)
]);
}
/**
* Handle keyup events.
* Primarily handles Escape key for dismissing modals/dialogs/selections.
* @private
* @param {KeyboardEvent} evt - Keyboard event
* @returns {boolean} False to prevent default
*/
#handleKeyUp(evt) {
// Allow feature hooks to intercept
if (this.#api.feature.on_key) {
if (this.#api.feature.on_key({up:evt})) return;
}
for (let handler of this.#api.feature.on_key2) {
if (handler({up:evt})) return;
}
switch (evt.keyCode) {
// escape
case 27:
// blur text input focus
this.#DOC.activeElement.blur();
// dismiss modals
this.#api.modal.hide();
// deselect widgets
this.#platform.deselect();
// hide all dialogs
this.#api.dialog.hide();
// cancel slicing
this.#api.function.cancel();
// trigger escape handlers (used by FDM mode for support editing)
this.#onEscape();
break;
}
return false;
}
/**
* Handle keydown events.
* Handles arrow keys for movement/rotation, delete key, and Cmd/Ctrl shortcuts.
* Arrow keys: rotate (default) or move (with Alt). Shift = smaller rotation.
* Meta + Up/Down: navigate layers.
* @private
* @param {KeyboardEvent} evt - Keyboard event
*/
#handleKeyDown(evt) {
if (this.#api.modal.visible()) {
return false;
}
// Allow feature hooks to intercept
if (this.#api.feature.on_key) {
if (this.#api.feature.on_key({down:evt})) return;
}
for (let handler of this.#api.feature.on_key2) {
if (handler({down:evt})) return;
}
let move = evt.altKey ? 5 : 0,
deg = move ? 0 : -Math.PI / (evt.shiftKey ? 36 : 2);
switch (evt.keyCode) {
case 8: // apple: delete/backspace
case 46: // others: delete
if (this.inputHasFocus()) return false;
this.#platform.delete(this.#selection.meshes());
evt.preventDefault();
break;
case 37: // left arrow
if (this.inputHasFocus()) return false;
if (deg) this.#selection.rotate(0, 0, -deg);
if (move > 0) this.#selection.move(-move, 0, 0);
evt.preventDefault();
break;
case 39: // right arrow
if (this.inputHasFocus()) return false;
if (deg) this.#selection.rotate(0, 0, deg);
if (move > 0) this.#selection.move(move, 0, 0);
evt.preventDefault();
break;
case 38: // up arrow
if (this.inputHasFocus()) return false;
if (evt.metaKey) {
const { hi } = this.#slider.getRange();
return this.#api.show.layer(hi + 1);
}
if (deg) this.#selection.rotate(deg, 0, 0);
if (move > 0) this.#selection.move(0, move, 0);
evt.preventDefault();
break;
case 40: // down arrow
if (this.inputHasFocus()) return false;
if (evt.metaKey) {
const { hi } = this.#slider.getRange();
return this.#api.show.layer(hi - 1);
}
if (deg) this.#selection.rotate(-deg, 0, 0);
if (move > 0) this.#selection.move(0, -move, 0);
evt.preventDefault();
break;
case 65: // 'a' for select all
if (evt.metaKey || evt.ctrlKey) {
if (this.inputHasFocus()) return false;
evt.preventDefault();
this.#platform.deselect();
this.#platform.select_all();
}
break;
case 83: // 's' for save workspace
if (evt.ctrlKey) {
evt.preventDefault();
this.#api.conf.save();
console.log("settings saved");
} else
if (evt.metaKey) {
evt.preventDefault();
this.#api.space.save();
this.#setCtrl.sync.put();
}
break;
case 76: // 'l' for restore workspace
if (evt.metaKey) {
evt.preventDefault();
this.#api.space.restore();
}
break;
}
}
/**
* Handle keypress events.
* Main keyboard shortcuts for application commands:
* - Numbers 0-9: show slice layers (0=all, 1-9=10-90%)
* - s/S: slice, p/P: prepare, x/X: export
* - a: arrange/layout, v: toggle single slice view
* - d: duplicate, m: mirror
* - i: import, r: recent files
* - e: devices, o: tools, l: load settings
* - Z: clear all settings, C: refresh catalog
* - Ctrl+g: group, Ctrl+u: ungroup
* @private
* @param {KeyboardEvent} evt - Keyboard event
* @returns {boolean} False to prevent default
*/
#handleKeyPress(evt) {
let handled = true;
if (this.#api.modal.visible() || this.inputHasFocus()) {
return false;
}
// Allow feature hooks to intercept
if (this.#api.feature.on_key) {
if (this.#api.feature.on_key({key:evt})) return;
}
for (let handler of this.#api.feature.on_key2) {
if (handler({key:evt})) return;
}
// Handle widget grouping (disabled: broken, conflicts with hash settings)
if (evt.ctrlKey) {
switch (evt.key) {
// case 'g': return this.#api.group.merge();
// case 'u': return this.#api.group.split();
case 'w': return $('render-edges').onclick();
}
}
let api = this.#api;
let isWireframe = api.local.getBoolean('model.wireframe');
let isGhost = !isWireframe && (api.local.getFloat('model.opacity') ?? 1.0) < 1;
switch (evt.keyCode) {
case this.#cca('`'): this.#api.show.slices(0); break;
case this.#cca('0'): {
const { max } = this.#slider.getRange();
this.#api.show.slices(max);
break;
}
case this.#cca('1'): {
const { max } = this.#slider.getRange();
this.#api.show.slices(max/10);
break;
}
case this.#cca('2'): {
const { max } = this.#slider.getRange();
this.#api.show.slices(max*2/10);
break;
}
case this.#cca('3'): {
const { max } = this.#slider.getRange();
this.#api.show.slices(max*3/10);
break;
}
case this.#cca('4'): {
const { max } = this.#slider.getRange();
this.#api.show.slices(max*4/10);
break;
}
case this.#cca('5'): {
const { max } = this.#slider.getRange();
this.#api.show.slices(max*5/10);
break;
}
case this.#cca('6'): {
const { max } = this.#slider.getRange();
this.#api.show.slices(max*6/10);
break;
}
case this.#cca('7'): {
const { max } = this.#slider.getRange();
this.#api.show.slices(max*7/10);
break;
}
case this.#cca('8'): {
const { max } = this.#slider.getRange();
this.#api.show.slices(max*8/10);
break;
}
case this.#cca('9'): {
const { max } = this.#slider.getRange();
this.#api.show.slices(max*9/10);
break;
}
case this.#cca('?'):
this.#api.help.show();
break;
case this.#cca('Z'): // reset stored state
this.#uc.confirm('clear all settings and preferences?').then(yes => {
if (yes) {
this.#sdb.clear();
this.#WIN.location.reload();
}
});
break;
case this.#cca('C'): // refresh catalog
this.#catalog.refresh();
break;
case this.#cca('i'): // file import
this.#api.event.import();
break;
case this.#cca('S'): // slice
case this.#cca('s'): // slice
this.#api.function.slice();
break;
case this.#cca('P'): // prepare
case this.#cca('p'): // prepare
if (this.#api.mode.get() !== 'SLA') {
// hidden in SLA mode
this.#api.function.prepare();
}
break;
case this.#cca('X'): // export
case this.#cca('x'): // export
this.#api.function.export();
break;
case this.#cca('g'): // CAM animate
this.#api.function.animate();
break;
case this.#cca('O'): // manual rotation
this.#rotateInputSelection();
break;
case this.#cca('r'): // recent files
this.#api.modal.show('files');
break;
case this.#cca('q'): // preferences
this.#api.modal.show('prefs');
break;
case this.#cca('l'): // device
this.#settingsLoad();
break;
case this.#cca('e'): // device
this.#api.show.devices();
break;
case this.#cca('o'): // tools
this.#api.show.tools();
break;
case this.#cca('v'): // toggle single slice view mode
if (this.#api.view.get() === this.#VIEWS.ARRANGE) {
this.#api.space.set_focus(this.#selection.widgets());
}
const { lo, hi } = this.#slider.getRange();
if (hi === lo) {
this.#slider.setRange(0, hi);
} else {
this.#slider.setRange(hi, hi);
}
this.#api.show.slices();
break;
case this.#cca('d'): // duplicate object
this.#selection.duplicate();
break;
case this.#cca('m'): // mirror object
this.#selection.mirror();
break;
case this.#cca('a'):
if (this.#api.view.get() === this.#VIEWS.ARRANGE) {
// auto arrange items on platform
this.#platform.layout();
if (!this.#api.conf.get().controller.spaceRandoX) {
this.#api.space.set_focus(this.#selection.widgets());
}
} else {
// go to arrange view
this.#api.view.set(this.#VIEWS.ARRANGE);
}
break;
case this.#cca('w'): // set ghost
if (isGhost && !isWireframe) {
$('render-solid').onclick();
} else {
$('render-ghost').onclick();
}
break;
case this.#cca('W'): // set wireframe
if (isWireframe) {
$('render-solid').onclick();
} else {
$('render-wire').onclick();
}
break;
default:
this.#onUnhandledKey(evt);
handled = false;
break;
}
if (handled) {
evt.preventDefault();
evt.stopPropagation();
}
return false;
}
/**
* Called when escape key is pressed
* Replaces api.event.emit('key.esc')
*/
#onEscape() {
// Notify listeners (currently used by FDM mode for support editing)
this.#api.event.emit("key.esc");
}
/**
* Called when an unhandled key is pressed
* Replaces api.event.emit('keypress', evt)
*/
#onUnhandledKey(evt) {
// Notify listeners for custom key handling
this.#api.event.emit('keypress', evt);
}
}
// Export singleton instance
export const keyboard = new KeyboardControl();

View file

@ -1,10 +1,23 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */ /** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
/**
* Global language/translation system.
* Stores language maps and tracks the current language selection.
*/
const LANG = self.lang = { current: {} }; const LANG = self.lang = { current: {} };
/** Default language code */
const KDFL = 'en-us'; const KDFL = 'en-us';
/** Currently selected language code from browser */
let lset = navigator.language.toLocaleLowerCase(); let lset = navigator.language.toLocaleLowerCase();
/**
* Map language codes to supported language identifiers.
* Normalizes short codes (en, fr, de) to full language codes (en-us, fr-fr, de-de).
* @param {string} key - Language code (e.g., 'en', 'fr', 'de-de')
* @returns {string} Normalized language code or default 'en-us'
*/
LANG.map = function(key) { LANG.map = function(key) {
if (!key) { if (!key) {
return KDFL; return KDFL;
@ -23,10 +36,21 @@ LANG.map = function(key) {
return KDFL; return KDFL;
}; };
/**
* Get current browser language setting.
* @returns {string} Browser language code
*/
LANG.get = function() { LANG.get = function() {
return lset; return lset;
}; };
/**
* Set language by trying a list of language codes in order.
* Falls back to browser language, then English if no arguments provided.
* Populates LANG.current with translations, filling missing keys from English.
* @param {...string} keys - Language codes to try in order
* @returns {string|undefined} Selected language code or undefined if none found
*/
LANG.set = function() { LANG.set = function() {
let map, key, keys = [...arguments]; let map, key, keys = [...arguments];
// provide default if none given // provide default if none given

View file

@ -3,11 +3,19 @@
import { newPolygon, Polygon } from '../../geo/polygon.js'; import { newPolygon, Polygon } from '../../geo/polygon.js';
import { polygons as POLY } from '../../geo/polygons.js'; import { polygons as POLY } from '../../geo/polygons.js';
/**
* Layer management system for 3D visualization.
* Organizes geometry (lines, polygons, faces, paths) by layer with color/opacity.
* Supports multiple rendering styles: basic lines, webgl lines, filled areas, 3D extrusion paths.
*/
export class Layers { export class Layers {
constructor() { constructor() {
this.init(); this.init();
} }
/**
* Initialize or reset layers, profiles, and statistics.
*/
init() { init() {
this.layers = {}; this.layers = {};
this.profiles = {}; this.profiles = {};
@ -20,21 +28,47 @@ export class Layers {
}; };
} }
/**
* Get layer data by layer index.
* @param {number} layer - Layer index
* @returns {object} Layer data or undefined
*/
getLayer(layer) { getLayer(layer) {
return this.layers[layer]; return this.layers[layer];
} }
// in radians /**
* Set rotation for subsequent layers.
* @param {number} [x=0] - X rotation in radians
* @param {number} [y=0] - Y rotation in radians
* @param {number} [z=0] - Z rotation in radians
* @returns {Layers} This instance for chaining
*/
setRotation(x = 0, y = 0, z = 0) { setRotation(x = 0, y = 0, z = 0) {
this.rotation = { x, y, z }; this.rotation = { x, y, z };
return this; return this;
} }
/**
* Set position offset for subsequent layers.
* @param {number} [x=0] - X position
* @param {number} [y=0] - Y position
* @param {number} [z=0] - Z position
* @returns {Layers} This instance for chaining
*/
setPosition(x = 0, y = 0, z = 0) { setPosition(x = 0, y = 0, z = 0) {
this.position = { x, y, z }; this.position = { x, y, z };
return this; return this;
} }
/**
* Set current layer for subsequent geometry additions.
* Creates layer if it doesn't exist.
* @param {number} layer - Layer index
* @param {number|object} colors - Single color number or {line, face, opacity}
* @param {boolean} [off] - If true, marks layer as off/hidden
* @returns {Layers} This instance for chaining
*/
setLayer(layer, colors, off) { setLayer(layer, colors, off) {
let layers = this.layers; let layers = this.layers;
if (typeof(colors) === 'number') { if (typeof(colors) === 'number') {
@ -65,13 +99,24 @@ export class Layers {
return this; return this;
} }
// add a line segment (two points) /**
* Add a single line segment to current layer.
* @param {object} p1 - First point {x, y, z}
* @param {object} p2 - Second point {x, y, z}
* @returns {Layers} This instance for chaining
*/
addLine(p1, p2) { addLine(p1, p2) {
this.current.lines.push(p1, p2); this.current.lines.push(p1, p2);
return this; return this;
} }
// add an array of line segments /**
* Add multiple line segments to current layer.
* If options provided, converts to open polygons via addPolys.
* @param {Array} lines - Array of points (alternating pairs for line segments)
* @param {object} [options] - If provided, creates open polygons instead
* @returns {Layers} This instance for chaining
*/
addLines(lines, options) { addLines(lines, options) {
if (options) { if (options) {
// the open option encodes lines as open polygons // the open option encodes lines as open polygons
@ -92,12 +137,23 @@ export class Layers {
return this; return this;
} }
// an open or closed polygon /**
* Add a single polygon to current layer.
* @param {Polygon} poly - Polygon to add
* @param {object} [options] - Rendering options
* @returns {Layers} This instance for chaining
*/
addPoly(poly, options) { addPoly(poly, options) {
return this.addPolys([ poly ], options); return this.addPolys([ poly ], options);
} }
// a polygon rendered as a webgl line /**
* Add polygons rendered as WebGL lines.
* Dispatches to addFlats or addPaths based on options.
* @param {Array<Polygon>} polys - Array of polygons
* @param {object} [options] - Options: {clean, flat, thin, z, color}
* @returns {Layers} This instance for chaining
*/
addPolys(polys, options) { addPolys(polys, options) {
if (!polys) { if (!polys) {
return this; return this;
@ -134,8 +190,13 @@ export class Layers {
return this; return this;
} }
// add an enclosed 3D polygon earcut into faces /**
// used for FDM solids, bridges, flats debug, CAM hole generation & SLA slice visualization * Add enclosed 3D polygon earcut into triangular faces.
* Used for FDM solids, bridges, flats debug, CAM hole generation, SLA slice visualization.
* @param {Polygon|Array<Polygon>} polys - Polygon(s) to tessellate
* @param {object} [options] - Options: {outline} to also draw polygon outline
* @returns {Layers} This instance for chaining
*/
addAreas(polys, options) { addAreas(polys, options) {
const faces = this.current.faces; const faces = this.current.faces;
polys = Array.isArray(polys) ? polys : [ polys ]; polys = Array.isArray(polys) ? polys : [ polys ];
@ -151,7 +212,13 @@ export class Layers {
} }
} }
// add a 2D polyline path (usually FDM extrusion paths) /**
* Add 2D polyline paths with width (usually FDM extrusion paths).
* Creates flat ribbons with vertex normals for lighting.
* @param {Array<Polygon>} polys - Polygons to render as flat ribbons
* @param {object} [options] - Options: {offset, outline, color}
* @returns {Layers} This instance for chaining
*/
addFlats(polys, options) { addFlats(polys, options) {
const opts = options || {}; const opts = options || {};
const offset = opts.offset || 1; const offset = opts.offset || 1;
@ -200,7 +267,14 @@ export class Layers {
return this; return this;
} }
// add 3D polyline path (FDM extrusion paths) /**
* Add 3D polyline paths with extrusion (FDM extrusion paths).
* Creates 3D tubes with vertex normals for lighting.
* Supports incremental merging and per-segment color changes.
* @param {Array<Polygon>} polys - Polygons to render as 3D tubes
* @param {object} [options] - Options: {height, offset, z, color}
* @returns {Layers} This instance for chaining
*/
addPaths(polys, options) { addPaths(polys, options) {
const opts = options || {}; const opts = options || {};
const height = opts.height || 1; const height = opts.height || 1;
@ -259,6 +333,12 @@ export class Layers {
} }
} }
/**
* Flatten polygon array or single polygon for rendering.
* Clones and flattens nested polygon structures.
* @param {Polygon|Array<Polygon>} polys - Polygon(s) to flatten
* @returns {Array<Polygon>} Flattened polygon array
*/
function flat(polys) { function flat(polys) {
if (Array.isArray(polys)) { if (Array.isArray(polys)) {
return POLY.flatten(polys.clone(true), [], true); return POLY.flatten(polys.clone(true), [], true);

55
src/kiri/app/local.js Normal file
View file

@ -0,0 +1,55 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
// Lazily access api to avoid circular dependency at module load time
let _api;
function getAPI() {
if (!_api) {
_api = self.kiri_api;
}
return _api;
}
function localGet(key) {
const api = getAPI();
let sloc = api.conf.get().local;
return sloc[key] || api.sdb[key];
}
function localSet(key, val) {
const api = getAPI();
let sloc = api.conf.get().local;
sloc[key] = api.sdb[key] = val;
return val;
}
function localRemove(key) {
const api = getAPI();
let sloc = api.conf.get().local;
return delete sloc[key];
}
function localGetBoolean(key, def = true) {
let val = localGet(key);
return val === true || val === 'true' || val === def;
}
export const local = {
get: (key) => localGet(key),
getItem: (key) => localGet(key),
getInt: (key) => parseInt(localGet(key)),
getFloat: (key) => {
const val = localGet(key);
if (val === undefined || val === null) return null;
const parsed = parseFloat(val);
return isNaN(parsed) ? null : parsed;
},
getBoolean: localGetBoolean,
toggle: (key, val, def) => {
return localSet(key, val ?? !localGetBoolean(key, def));
},
put: (key, val) => localSet(key, val),
set: (key, val) => localSet(key, val),
setItem: (key, val) => localSet(key, val),
removeItem: (key) => localRemove(key)
};

119
src/kiri/app/modal.js Normal file
View file

@ -0,0 +1,119 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
/**
* Modal Manager (Singleton)
* Manages modal dialog display and animations
*/
class ModalControl {
#ui;
#onShow;
#onHide;
#showing;
#initialized = false;
/**
* Initialize the modal controller with dependencies
* @param {Object} config
* @param {Object} config.ui - UI element references
* @param {HTMLElement} config.ui.modal - Modal container element
* @param {Object} config.ui.modals - Object containing all modal elements by name
* @param {Function} config.onShow - Optional callback when modal is shown
* @param {Function} config.onHide - Optional callback when modal is hidden
*/
init({ ui, onShow, onHide }) {
if (this.#initialized) {
throw new Error('Modal already initialized');
}
this.#ui = ui;
this.#onShow = onShow;
this.#onHide = onHide;
this.#initialized = true;
return this;
}
/**
* Check if modal is currently visible
* @returns {boolean}
*/
visible() {
return modal.#ui.modal.style.display === 'flex';
}
/**
* Check if a specific modal is currently visible
* @returns {boolean}
*/
is(name) {
return modal.#showing === name;
}
/**
* Show a specific modal with animation
* @param {string} which - Name of the modal to show
*/
show(which) {
let mod = modal.#ui.modal,
style = mod.style,
visible = modal.visible(),
info = { pct: 0 };
// if the dialog needs it, it will re-add it
document.body.classList.remove('devel');
// hide all modals before showing another
Object.keys(modal.#ui.modals).forEach(name => {
modal.#ui.modals[name].style.display = name === which ? 'flex' : '';
});
modal.#showing = which;
const ondone = () => {
modal.#onShow?.(which);
};
if (visible) {
return ondone();
}
style.height = '0';
style.display = 'flex';
new TWEEN.Tween(info).
easing(TWEEN.Easing.Quadratic.InOut).
to({ pct: 100 }, 100).
onUpdate(() => { style.height = `${info.pct}%` }).
onComplete(ondone).
start();
}
/**
* Hide the modal with animation
*/
hide() {
if (!modal.visible()) {
return;
}
modal.#showing = undefined;
let mod = modal.#ui.modal,
style = mod.style,
info = { pct: 100 };
new TWEEN.Tween(info).
easing(TWEEN.Easing.Quadratic.InOut).
to({ pct: 0 }, 100).
onUpdate(() => { style.height = `${info.pct}%` }).
onComplete(() => {
style.display = '';
modal.#onHide?.();
}).
start();
}
}
// Export singleton instance
export const modal = new ModalControl();

148
src/kiri/app/mode.js Normal file
View file

@ -0,0 +1,148 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { $ } from '../../moto/webui.js';
import { api } from './api.js';
import { consts } from './consts.js';
import { modal } from './modal.js';
import { platform } from './platform.js';
import { selection } from './selected.js';
import { settings } from './conf/manager.js';
const { MODES, VIEWS } = consts;
const clone = Object.clone;
/** Current operating mode ID */
let MODE = MODES.FDM;
/**
* Get current mode name as string.
* @returns {string} Mode name (FDM, CAM, SLA, LASER, etc.)
*/
function getMode() {
return settings.mode();
}
/**
* Get current mode name in lowercase.
* @returns {string} Lowercase mode name (fdm, cam, sla, etc.)
*/
function getModeLower() {
return getMode().toLowerCase();
}
/**
* Switch to a different operating mode.
* Convenience wrapper around setMode() that updates platform size after switch.
* @param {string} mode - Target mode name (FDM, CAM, SLA, LASER, DRAG, WJET, WEDM)
*/
function switchMode(mode) {
setMode(mode, null, platform.update_size);
}
/**
* Set operating mode with full initialization.
* Complex function that:
* - Validates and sets mode constant
* - Updates UI to show/hide mode-specific controls
* - Restores cached device for the mode
* - Resets view to ARRANGE
* - Saves settings
* - Updates platform and selection
* - Emits mode.set event
*
* @param {string} mode - Target mode name (FDM, CAM, SLA, LASER, DRAG, WJET, WEDM)
* @param {*} lock - Currently unused parameter
* @param {function} [then] - Optional callback after mode set complete
*/
function setMode(mode, lock, then) {
if (!MODES[mode]) {
console.log("invalid mode: "+mode);
mode = 'FDM';
}
const current = settings.get();
// change mode constants
current.mode = mode;
MODE = MODES[mode];
// gcode edit area for any non-SLA mode
api.uc.setVisible($('gcode-edit'), mode !== 'SLA');
// highlight selected mode menu item
["FDM","CAM","SLA","LASER","DRAG","WJET","WEDM"].forEach(sm => {
const cl = $(`mode-${sm.toLowerCase()}`).classList;
if (sm === mode) {
cl.add('selected');
} else {
cl.remove('selected');
}
});
// restore cached device profile for this mode
if (current.cdev[mode]) {
current.device = clone(current.cdev[mode]);
api.event.emit('device.select', api.device.get());
}
// hide/show
api.uc.setVisible($('set-tools'), mode === 'CAM');
// updates right-hand menu by enabling/disabling fields
api.view.set(VIEWS.ARRANGE);
api.uc.setMode(MODE);
// sanitize and persist settings
api.conf.load();
api.conf.save();
// trigger settings event
api.event.emit('settings', settings.get());
// other housekeeping
platform.update_selected();
selection.update_bounds(api.widgets.all());
api.conf.update_fields();
// if device dialog showing, needs to be refreshed
if (modal.is('setup')) {
api.show.devices();
}
api.space.restore(null, true);
api.event.emit("mode.set", mode);
if (then) {
then();
}
}
/**
* Get the name of the current process profile for this mode.
* @returns {string} Process profile name
*/
function currentProcessName() {
return settings.get().cproc[getMode()];
}
/**
* Get the process configuration object for the current mode and profile.
* @returns {object} Process configuration
*/
function currentProcessCode() {
return settings.get().sproc[getMode()][currentProcessName()];
}
export const mode = {
get_id() { return MODE },
get_lower: getModeLower,
get: getMode,
set: setMode,
switch: switchMode,
set_expert() {}, // noop
is_fdm() { return MODE === MODES.FDM },
is_cam() { return MODE === MODES.CAM },
is_sla() { return MODE === MODES.SLA },
is_drag() { return MODE === MODES.DRAG },
is_wedm() { return MODE === MODES.WEDM },
is_wjet() { return MODE === MODES.WJET },
is_laser() { return MODE === MODES.LASER },
is_2d() { return false ||
mode.is_drag() ||
mode.is_wedm() ||
mode.is_wjet() ||
mode.is_laser()
}
};
export const process = {
code: currentProcessCode,
get: currentProcessName
};

218
src/kiri/app/mouse.js Normal file
View file

@ -0,0 +1,218 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
/**
* Interaction Controller (Singleton)
* Manages mouse hover, click, and drag interactions with 3D objects
*/
class InteractionControl {
#api;
#space;
#platform;
#selection;
#ui;
#settings;
#VIEWS;
#initialized = false;
// Callbacks to replace event emissions
#onHover;
#onHoverDown;
#onHoverUp;
#onDragDone;
#onSelectionDrag;
/**
* Initialize the interaction controller with dependencies
* @param {Object} config
* @param {Object} config.api - API object
* @param {Object} config.space - Space object (3D scene)
* @param {Object} config.platform - Platform object
* @param {Object} config.selection - Selection object
* @param {Object} config.ui - UI element references
* @param {Function} config.settings - Settings getter function
* @param {Object} config.VIEWS - View constants
* @param {Function} config.onHover - Called when hovering over objects
* @param {Function} config.onHoverDown - Called on mouse down over object
* @param {Function} config.onHoverUp - Called on mouse up over object
* @param {Function} config.onDragDone - Called when drag is complete
* @param {Function} config.onSelectionDrag - Called during selection drag
*/
init({ api, space, platform, selection, ui, settings, VIEWS, onHover, onHoverDown, onHoverUp, onDragDone, onSelectionDrag }) {
if (this.#initialized) {
throw new Error('Interactions already initialized');
}
this.#api = api;
this.#space = space;
this.#platform = platform;
this.#selection = selection;
this.#ui = ui;
this.#settings = settings;
this.#VIEWS = VIEWS;
this.#onHover = onHover;
this.#onHoverDown = onHoverDown;
this.#onHoverUp = onHoverUp;
this.#onDragDone = onDragDone;
this.#onSelectionDrag = onSelectionDrag;
this.#bindHoverHandlers();
this.#bindMouseHandlers();
this.#initialized = true;
}
/**
* Bind hover event handlers for mouse and platform interactions.
* Listens to "feature.hover" event to enable/disable hover functionality.
* @private
*/
#bindHoverHandlers() {
// Set up hover handlers
this.#api.event.on("feature.hover", enable => {
this.#space.mouse.onHover(enable ? this.#mouseOnHover.bind(this) : undefined);
this.#space.platform.onHover(enable ? this.#platformOnHover.bind(this) : undefined);
});
}
/**
* Handle mouse hover over widgets.
* Returns widget meshes if no intersection, otherwise triggers hover callback.
* @private
* @param {object} int - Intersection data
* @param {Event} event - Mouse event
* @param {Array} ints - All intersections
*/
#mouseOnHover(int, event, ints) {
if (!this.#api.feature.hover) return;
if (!int) return this.#api.feature.hovers || this.#api.widgets.meshes();
this.#onHover?.({int, ints, event, point: int.point, type: 'widget'});
}
/**
* Handle mouse hover over platform.
* Triggers hover callback with platform intersection point.
* @private
* @param {object} int - Intersection point
* @param {Event} event - Mouse event
*/
#platformOnHover(int, event) {
if (!this.#api.feature.hover) return;
if (int) this.#onHover?.({point: int, event, type: 'platform'});
}
/**
* Bind mouse click and drag handlers.
* Handles:
* - Mouse down: lay-flat (Ctrl/Cmd+click), custom hooks, hover mode
* - Mouse up: widget selection/deselection, custom hooks
* - Drag: move selected widgets with boundary checking
* @private
*/
#bindMouseHandlers() {
// Mouse down handler
this.#space.mouse.downSelect((int, event) => {
// Feature hook for custom mouse down handling
if (this.#api.feature.on_mouse_down) {
return this.#api.feature.on_mouse_down(int, event);
}
// Hover mode handling
if (this.#api.feature.hover) {
if (int) {
this.#onHoverDown?.({int, point: int.point});
return;
} else {
return this.#selection.meshes();
}
}
// Lay flat with meta or ctrl clicking a selected face
if (int && (event.ctrlKey || event.metaKey || this.#api.feature.on_face_select)) {
let q = new THREE.Quaternion();
// find intersecting point, look "up" on Z and rotate to face that
q.setFromUnitVectors(int.face.normal, new THREE.Vector3(0,0,-1));
this.#selection.rotate(q);
}
if (this.#api.view.get() !== this.#VIEWS.ARRANGE) {
// return no selection in modes other than arrange
return null;
} else {
// return selected meshes for further mouse processing
return this.#api.feature.hovers || this.#selection.meshes();
}
});
// Mouse up handler
this.#space.mouse.upSelect((object, event) => {
// Feature hook for custom mouse up handling
if (this.#api.feature.on_mouse_up) {
if (event && object) {
return this.#api.feature.on_mouse_up(object, event);
} else {
return this.#api.widgets.meshes();
}
}
// Hover mode handling
if (event && this.#api.feature.hover) {
this.#onHoverUp?.({ object, event });
return;
}
// Regular selection handling
if (event && event.target.nodeName === "CANVAS") {
if (object && object.object) {
if (object.object.widget) {
this.#platform.select(object.object.widget, event.shiftKey, false);
}
} else {
this.#platform.deselect();
}
} else {
return this.#api.feature.hovers || this.#api.widgets.meshes();
}
});
// Drag handler
this.#space.mouse.onDrag((delta, offset, up = false, int) => {
if (this.#api.feature.hover) {
return this.#api.feature?.on_mouse_drag({ delta, offset, up, int });
// return;
}
if (up) {
this.#onDragDone?.(offset);
}
if (delta && this.#ui.freeLayout.checked) {
let set = this.#settings();
let dev = set.device;
let bound = set.bounds_sel;
let width = dev.bedWidth/2;
let depth = dev.bedDepth/2;
let isout = (
bound.min.x <= -width ||
bound.min.y <= -depth ||
bound.max.x >= width ||
bound.max.y >= depth
);
if (!isout) {
if (bound.min.x + delta.x <= -width) return;
if (bound.min.y + delta.y <= -depth) return;
if (bound.max.x + delta.x >= width) return;
if (bound.max.y + delta.y >= depth) return;
}
this.#selection.move(delta.x, delta.y, 0);
this.#onSelectionDrag?.(delta);
} else {
return this.#selection.meshes().length > 0;
}
});
}
}
// Export singleton instance
export const interact = new InteractionControl();

555
src/kiri/app/paint.js Normal file
View file

@ -0,0 +1,555 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
/**
* Paint overlay shaders for mesh painting functionality.
*
* Provides two implementations for visualizing painted regions on mesh surfaces:
* 1. Simple uniform-based (up to ~256 paint points)
* 2. Texture-based (unlimited paint points)
*
* Paint points are spheres defined by center position and radius.
* The shader checks if each fragment falls within any paint sphere and
* blends the paint color accordingly.
*
* SURFACE FILTERING:
* By default, painting is constrained to downward-facing surfaces only (z-normal < 0).
* This prevents paint "spill" onto adjacent upward-facing surfaces when painting
* support regions. For multi-material coloring or other uses, set
* options.onlyDownwardFacing = false to paint all surfaces.
*
* BELT PRINTER MODE:
* For belt printers, surfaces need support when facing "down the belt slope" rather
* than straight down. Set options.beltMode = true and provide options.beltAngle (in radians)
* to paint surfaces that face down the angled belt direction.
*
* PAINT POINT DATA STRUCTURE:
* ---------------------------
* Paint points must be an array of objects with this structure:
*
* {
* point: { x: number, y: number, z: number }, // World-space center of paint sphere
* radius: number // Sphere radius in mm
* }
*
* Example:
* const paintPoints = [
* { point: { x: 10.5, y: 20.3, z: 15.0 }, radius: 5.0 },
* { point: { x: 12.0, y: 21.0, z: 15.5 }, radius: 5.0 },
* ...
* ];
*
* USAGE:
* ------
* // Initial setup - enable paint overlay (for support painting, downward-facing only):
* widget._origMaterial = widget.mesh.material;
* widget.mesh.material = widget.mesh.material.clone();
* addPaintOverlayAuto(widget.mesh.material, paintPoints, new THREE.Color(0x4488ff));
* widget.mesh.material.needsUpdate = true;
*
* // For multi-material coloring (paint all surfaces):
* addPaintOverlayAuto(widget.mesh.material, paintPoints, new THREE.Color(0xff0000), { onlyDownwardFacing: false });
*
* // Real-time updates while painting (see updatePaintOverlay function below)
*
* // Cleanup - restore original material:
* widget.mesh.material = widget._origMaterial;
* delete widget._origMaterial;
*
* REAL-TIME UPDATES:
* ------------------
* When actively painting (user dragging mouse), paint points are added continuously.
* Use updatePaintOverlay() to efficiently update the shader without recreating material:
*
* // Add new paint point
* paintPoints.push({ point: { x, y, z }, radius: 5 });
*
* // Update shader to show new point immediately
* updatePaintOverlay(widget.mesh.material, paintPoints);
*
* Note: After onBeforeCompile runs once, the shader program is compiled and cached.
* Subsequent updates only need to update the uniform values, which is very fast.
*/
import { THREE } from '../../ext/three.js';
/**
* Add paint overlay to material using uniform array storage.
* Suitable for up to ~256 paint points (GPU uniform limit varies).
*
* Uses sphere-based distance checks to determine if a fragment is painted.
* Paint color is blended at 50% opacity with the underlying material color.
*
* @param {THREE.Material} material - Material to modify (will be mutated via onBeforeCompile)
* @param {Array<{point: {x,y,z}, radius: number}>} paintPoints - Array of paint spheres
* @param {THREE.Color} [paintColor] - Color for painted regions (default: light blue)
* @param {Object} [options] - Optional configuration
* @param {boolean} [options.onlyDownwardFacing=true] - Only paint on downward-facing surfaces (z-normal < 0)
* @param {boolean} [options.beltMode=false] - Enable belt printer mode (paints surfaces facing down belt slope)
* @param {number} [options.beltAngle=0] - Belt angle in radians (only used if beltMode is true)
* @param {number} [options.supportAngle=45] - Support angle in degrees (cone around down direction)
* @returns {THREE.Material} The modified material
*/
function addPaintOverlaySimple(material, paintPoints, paintColor = new THREE.Color(0x4488ff), options = {}) {
// Initialize userData if it doesn't exist
if (!material.userData) {
material.userData = {};
}
// Default: only paint downward-facing surfaces (for support painting)
const onlyDownwardFacing = options.onlyDownwardFacing !== undefined ? options.onlyDownwardFacing : true;
const beltMode = options.beltMode || false;
const beltAngle = options.beltAngle || 0;
const supportAngle = options.supportAngle || 45;
const supportThreshold = Math.cos((90 - supportAngle) * Math.PI / 180);
// Store initial paint points reference
material.userData.paintPoints = paintPoints;
material.userData.paintColor = paintColor;
material.onBeforeCompile = (shader) => {
// Store shader reference for later updates
material.userData.shader = shader;
// Use updated paint points if they were modified before first compile
const currentPoints = material.userData.paintPoints || paintPoints;
// Convert paint points to vec4 array (x, y, z, radius)
// Filter out invalid entries
const validPoints = currentPoints
.filter(p => p && p.point && typeof p.radius === 'number')
.map(p => new THREE.Vector4(p.point.x, p.point.y, p.point.z, p.radius));
// Pad array to exactly 256 elements (shader uniform array size)
// THREE.js requires the array length to match the shader declaration
const points = new Array(256);
for (let i = 0; i < 256; i++) {
points[i] = i < validPoints.length ? validPoints[i] : new THREE.Vector4(0, 0, 0, 0);
}
// Add uniforms for paint data
shader.uniforms.paintPoints = { value: points };
shader.uniforms.paintCount = { value: validPoints.length };
shader.uniforms.paintColor = { value: paintColor };
shader.uniforms.onlyDownwardFacing = { value: onlyDownwardFacing };
shader.uniforms.beltMode = { value: beltMode };
shader.uniforms.beltAngle = { value: beltAngle };
shader.uniforms.supportThreshold = { value: supportThreshold };
// Pass model position from vertex shader to fragment shader
// Paint points are in widget/model space, so we compare against transformed (model space)
shader.vertexShader = shader.vertexShader.replace(
`#include <worldpos_vertex>`,
`
#include <worldpos_vertex>
vWorldPosition = vec3(transformed);
`
);
// Don't pass normals from vertex shader at all - we'll use fragment shader's normal
shader.vertexShader = `
varying vec3 vWorldPosition;
` + shader.vertexShader;
// Fragment shader: check distance to each paint sphere
shader.fragmentShader = `
varying vec3 vWorldPosition;
uniform vec4 paintPoints[256]; // (x, y, z, radius)
uniform int paintCount;
uniform vec3 paintColor;
uniform bool onlyDownwardFacing;
uniform bool beltMode;
uniform float beltAngle;
uniform float supportThreshold;
` + shader.fragmentShader;
shader.fragmentShader = shader.fragmentShader.replace(
`#include <dithering_fragment>`,
`
#include <dithering_fragment>
// Compute world-space normal from position derivatives
vec3 posDx = dFdx(vWorldPosition);
vec3 posDy = dFdy(vWorldPosition);
vec3 worldNormal = normalize(cross(posDx, posDy));
// Only paint downward-facing surfaces if option is enabled
bool shouldPaint;
if (beltMode) {
// Belt mode: mesh is rotated, check if normal points toward world down
// World down (0,0,-1) appears as (0, -sin(θ), -cos(θ)) in rotated mesh space
// Dot product: normal · (0, -sin(θ), -cos(θ)) = -sin(θ)*ny - cos(θ)*nz
float sinAngle = sin(beltAngle);
float cosAngle = cos(beltAngle);
float rotatedZ = worldNormal.y * sinAngle + worldNormal.z * cosAngle;
// Check if within support angle cone from down direction (rotatedZ < -threshold)
shouldPaint = !onlyDownwardFacing || rotatedZ < -supportThreshold;
} else {
// Normal mode: check if normal points downward within support angle cone
shouldPaint = !onlyDownwardFacing || worldNormal.z < -supportThreshold;
}
if (shouldPaint) {
// Check if this fragment is within any paint sphere
bool painted = false;
for (int i = 0; i < 256; i++) {
if (i >= paintCount) break;
vec3 center = paintPoints[i].xyz;
float radius = paintPoints[i].w;
float dist = distance(vWorldPosition, center);
if (dist <= radius) {
painted = true;
break; // Early exit once painted
}
}
if (painted) {
// Blend paint color with existing fragment color (increased to 0.7 for visibility)
gl_FragColor.rgb = mix(gl_FragColor.rgb, paintColor, 0.7);
}
} // End normal check
// DEBUG: Uncomment to tint entire mesh when shader is active (proves shader is running)
// if (paintCount > 0) {
// gl_FragColor.rgb += vec3(0.3, 0.0, 0.0); // Add red tint to entire mesh
// }
`
);
};
return material;
}
/**
* Add paint overlay using texture-based point storage.
* Scales to unlimited paint points by encoding them in a DataTexture.
*
* Each texel in the data texture stores one paint point as RGBA float:
* - R channel: x coordinate
* - G channel: y coordinate
* - B channel: z coordinate
* - A channel: radius
*
* @param {THREE.Material} material - Material to modify (will be mutated via onBeforeCompile)
* @param {Array<{point: {x,y,z}, radius: number}>} paintPoints - Array of paint spheres
* @param {THREE.Color} [paintColor] - Color for painted regions (default: light blue)
* @param {Object} [options] - Optional configuration
* @param {boolean} [options.onlyDownwardFacing=true] - Only paint on downward-facing surfaces (z-normal < 0)
* @param {boolean} [options.beltMode=false] - Enable belt printer mode (paints surfaces facing down belt slope)
* @param {number} [options.beltAngle=0] - Belt angle in radians (only used if beltMode is true)
* @param {number} [options.supportAngle=45] - Support angle in degrees (cone around down direction)
* @returns {THREE.Material} The modified material
*/
function addPaintOverlayTexture(material, paintPoints, paintColor = new THREE.Color(0x4488ff), options = {}) {
// Initialize userData if it doesn't exist
if (!material.userData) {
material.userData = {};
}
// Default: only paint downward-facing surfaces (for support painting)
const onlyDownwardFacing = options.onlyDownwardFacing !== undefined ? options.onlyDownwardFacing : true;
const beltMode = options.beltMode || false;
const beltAngle = options.beltAngle || 0;
const supportAngle = options.supportAngle || 45;
const supportThreshold = Math.cos((90 - supportAngle) * Math.PI / 180);
// Store initial paint points reference
material.userData.paintPoints = paintPoints;
material.userData.paintColor = paintColor;
material.onBeforeCompile = (shader) => {
// Store shader reference for later updates
material.userData.shader = shader;
// Use updated paint points if they were modified before first compile
const currentPoints = material.userData.paintPoints || paintPoints;
// Filter out invalid entries
const validPoints = currentPoints.filter(p => p && p.point && typeof p.radius === 'number');
// Create data texture from paint points
// Texture is laid out as square grid: width x height >= point count
const pointCount = validPoints.length;
const texWidth = Math.ceil(Math.sqrt(pointCount));
const texHeight = Math.ceil(pointCount / texWidth);
const data = new Float32Array(texWidth * texHeight * 4);
// Pack paint points into texture data
// Each point = 4 floats (x, y, z, radius)
for (let i = 0; i < validPoints.length; i++) {
const p = validPoints[i];
data[i * 4 + 0] = p.point.x;
data[i * 4 + 1] = p.point.y;
data[i * 4 + 2] = p.point.z;
data[i * 4 + 3] = p.radius;
}
// Create texture with float data
const texture = new THREE.DataTexture(
data,
texWidth,
texHeight,
THREE.RGBAFormat,
THREE.FloatType
);
texture.needsUpdate = true;
// Add uniforms for paint data
shader.uniforms.paintTexture = { value: texture };
shader.uniforms.paintCount = { value: pointCount };
shader.uniforms.paintTexSize = { value: new THREE.Vector2(texWidth, texHeight) };
shader.uniforms.paintColor = { value: paintColor };
shader.uniforms.onlyDownwardFacing = { value: onlyDownwardFacing };
shader.uniforms.beltMode = { value: beltMode };
shader.uniforms.beltAngle = { value: beltAngle };
shader.uniforms.supportThreshold = { value: supportThreshold };
// Pass model position from vertex shader to fragment shader
// Paint points are in widget/model space, so we compare against transformed (model space)
shader.vertexShader = shader.vertexShader.replace(
`#include <worldpos_vertex>`,
`
#include <worldpos_vertex>
vWorldPosition = vec3(transformed);
`
);
// Don't pass normals from vertex shader at all - we'll use fragment shader's normal
shader.vertexShader = `
varying vec3 vWorldPosition;
` + shader.vertexShader;
// Fragment shader: sample paint points from texture and check distances
shader.fragmentShader = `
varying vec3 vWorldPosition;
uniform sampler2D paintTexture;
uniform int paintCount;
uniform vec2 paintTexSize;
uniform vec3 paintColor;
uniform bool onlyDownwardFacing;
uniform bool beltMode;
uniform float beltAngle;
uniform float supportThreshold;
` + shader.fragmentShader;
shader.fragmentShader = shader.fragmentShader.replace(
`#include <dithering_fragment>`,
`
#include <dithering_fragment>
// Compute world-space normal from position derivatives
vec3 posDx = dFdx(vWorldPosition);
vec3 posDy = dFdy(vWorldPosition);
vec3 worldNormal = normalize(cross(posDx, posDy));
// Only paint downward-facing surfaces if option is enabled
bool shouldPaint;
if (beltMode) {
// Belt mode: mesh is rotated, check if normal points toward world down
// World down (0,0,-1) appears as (0, -sin(θ), -cos(θ)) in rotated mesh space
// Dot product: normal · (0, -sin(θ), -cos(θ)) = -sin(θ)*ny - cos(θ)*nz
float sinAngle = sin(beltAngle);
float cosAngle = cos(beltAngle);
float rotatedZ = worldNormal.y * sinAngle + worldNormal.z * cosAngle;
// Check if within support angle cone from down direction (rotatedZ < -threshold)
shouldPaint = !onlyDownwardFacing || rotatedZ < -supportThreshold;
} else {
// Normal mode: check if normal points downward within support angle cone
shouldPaint = !onlyDownwardFacing || worldNormal.z < -supportThreshold;
}
if (shouldPaint) {
bool painted = false;
// Iterate through paint points stored in texture
// Loop limit must be constant, but we break early when i >= paintCount
for (int i = 0; i < 10000; i++) {
if (i >= paintCount) break;
// Calculate texture coordinates for this point index
// Points are stored row-major in the texture
float u = (float(i % int(paintTexSize.x)) + 0.5) / paintTexSize.x;
float v = (float(i / int(paintTexSize.x)) + 0.5) / paintTexSize.y;
// Sample point data from texture (x, y, z, radius)
vec4 pointData = texture2D(paintTexture, vec2(u, v));
vec3 center = pointData.xyz;
float radius = pointData.w;
// Check if fragment is within this paint sphere
float dist = distance(vWorldPosition, center);
if (dist <= radius) {
painted = true;
break; // Early exit once painted
}
}
if (painted) {
// Blend paint color with existing fragment color (increased to 0.7 for visibility)
gl_FragColor.rgb = mix(gl_FragColor.rgb, paintColor, 0.7);
}
} // End normal check
// DEBUG: Uncomment to tint entire mesh when shader is active (proves shader is running)
// if (paintCount > 0) {
// gl_FragColor.rgb += vec3(0.3, 0.0, 0.0); // Add red tint to entire mesh
// }
`
);
};
return material;
}
/**
* Helper function to choose appropriate paint overlay method based on point count.
* Automatically selects simple or scalable version.
*
* @param {THREE.Material} material - Material to modify
* @param {Array<{point: {x,y,z}, radius: number}>} paintPoints - Array of paint spheres
* @param {THREE.Color} [paintColor] - Color for painted regions
* @param {Object} [options] - Optional configuration
* @param {boolean} [options.onlyDownwardFacing=true] - Only paint on downward-facing surfaces (z-normal < 0)
* @returns {THREE.Material} The modified material
*/
function addPaintOverlayAuto(material, paintPoints, paintColor = new THREE.Color(0x4488ff), options = {}) {
if (paintPoints.length <= 256) {
return addPaintOverlaySimple(material, paintPoints, paintColor, options);
} else {
return addPaintOverlayTexture(material, paintPoints, paintColor, options);
}
}
/**
* Update paint overlay with new paint points (for real-time painting).
* Call this after adding new points to the paintPoints array.
*
* This function updates the shader uniforms without recompiling the shader,
* making it suitable for real-time updates during mouse drag painting.
*
* IMPORTANT: The material must have already been initialized with one of the
* addPaintOverlay functions before calling this update function.
*
* NOTE: If the shader hasn't been compiled yet (first render not complete),
* this will trigger a material update to force recompilation with new points.
*
* @param {THREE.Material} material - Material with paint shader already applied
* @param {Array<{point: {x,y,z}, radius: number}>} paintPoints - Updated array of paint spheres
*/
function updatePaintOverlay(material, paintPoints) {
// Initialize userData if it doesn't exist
if (!material.userData) {
material.userData = {};
}
// Store paint points reference in userData for access during shader compilation
if (!material.userData.paintPoints) {
material.userData.paintPoints = paintPoints;
}
// Access the compiled shader uniforms
// After onBeforeCompile runs once, userData.shader contains the compiled program
const shader = material.userData?.shader;
if (!shader || !shader.uniforms) {
// Shader hasn't compiled yet (first render not complete)
// Store the new points and trigger recompile
material.userData.paintPoints = paintPoints;
material.needsUpdate = true;
return;
}
// Determine which shader type based on available uniforms
if (shader.uniforms.paintPoints) {
// Simple uniform-based shader
updatePaintOverlaySimple(shader, paintPoints);
} else if (shader.uniforms.paintTexture) {
// Texture-based shader
updatePaintOverlayTexture(shader, paintPoints);
} else {
console.warn('updatePaintOverlay: unrecognized paint shader type');
}
}
/**
* Update simple uniform-based paint shader.
* @private
*/
function updatePaintOverlaySimple(shader, paintPoints) {
const uniforms = shader.uniforms;
// Convert paint points to vec4 array (x, y, z, radius)
// Filter out invalid entries
const validPoints = paintPoints
.filter(p => p && p.point && typeof p.radius === 'number')
.map(p => new THREE.Vector4(p.point.x, p.point.y, p.point.z, p.radius));
// Pad array to exactly 256 elements (shader uniform array size)
const points = new Array(256);
for (let i = 0; i < 256; i++) {
points[i] = i < validPoints.length ? validPoints[i] : new THREE.Vector4(0, 0, 0, 0);
}
// Update uniform values
uniforms.paintPoints.value = points;
uniforms.paintCount.value = validPoints.length;
// Note: No need to set needsUpdate on the material
// Uniform updates are automatically detected by THREE.js
}
/**
* Update texture-based paint shader.
* @private
*/
function updatePaintOverlayTexture(shader, paintPoints) {
const uniforms = shader.uniforms;
// Filter out invalid entries
const validPoints = paintPoints.filter(p => p && p.point && typeof p.radius === 'number');
// Recreate texture with new paint points
const pointCount = validPoints.length;
const texWidth = Math.ceil(Math.sqrt(pointCount));
const texHeight = Math.ceil(pointCount / texWidth);
const data = new Float32Array(texWidth * texHeight * 4);
// Pack paint points into texture data
for (let i = 0; i < validPoints.length; i++) {
const p = validPoints[i];
data[i * 4 + 0] = p.point.x;
data[i * 4 + 1] = p.point.y;
data[i * 4 + 2] = p.point.z;
data[i * 4 + 3] = p.radius;
}
// Create new texture
const texture = new THREE.DataTexture(
data,
texWidth,
texHeight,
THREE.RGBAFormat,
THREE.FloatType
);
texture.needsUpdate = true;
// Dispose old texture to prevent memory leak
if (uniforms.paintTexture.value) {
uniforms.paintTexture.value.dispose();
}
// Update uniform values
uniforms.paintTexture.value = texture;
uniforms.paintCount.value = pointCount;
uniforms.paintTexSize.value.set(texWidth, texHeight);
}
export {
addPaintOverlaySimple,
addPaintOverlayTexture,
addPaintOverlayAuto,
updatePaintOverlay
};

View file

@ -1,32 +1,65 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */ /** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { $, h } from '../../moto/webui.js'; import { $, h } from '../../moto/webui.js';
import { ajax, js2o } from '../core/utils.js';
import { api } from './api.js'; import { api } from './api.js';
import { ajax, js2o } from './utils.js'; import { MODES } from './consts.js';
import { base } from '../../geo/base.js'; import { colorSchemeRegistry } from './color/schemes.js';
import { load } from '../../load/file.js'; import { load as file_load } from '../../load/file.js';
import { space } from '../../moto/space.js'; import { newBounds } from '../../geo/bounds.js';
import { Widget, newWidget } from './widget.js';
import { Packer } from './pack.js'; import { Packer } from './pack.js';
import { space } from '../../moto/space.js';
import { COLOR, MODES } from './consts.js';
import { THREE } from '../../ext/three.js'; import { THREE } from '../../ext/three.js';
import { Widget, newWidget } from './widget.js';
const V0 = new THREE.Vector3(0,0,0); const V0 = new THREE.Vector3(0,0,0);
/**
* Get current color scheme
*/
function getColorScheme() {
const mode = api.mode.get_id();
const theme = api.space.is_dark() ? 'dark' : 'light';
return colorSchemeRegistry.getScheme(mode, theme);
}
/** Bounds update callback */
let setbounds = undefined; let setbounds = undefined;
/** Flag indicating if grouping operation is in progress */
let grouping = false; let grouping = false;
/** Maximum Z height of all widgets on platform */
let topZ = 0; let topZ = 0;
/**
* Get current settings object.
* @returns {object} Current configuration
*/
function current() { function current() {
return api.conf.get(); return api.conf.get();
} }
/**
* Get current mode ID constant.
* @returns {number} Mode ID
*/
function get_mode() { function get_mode() {
return api.mode.get_id(); return api.mode.get_id();
} }
function platformUpdateOrigin(update_bounds = true) { /**
* Update platform origin position and rulers.
* Calculates origin based on mode, device, and process settings:
* - FDM: Corner or center based on device.originCenter/bedRound
* - CAM: Relative to stock, with optional top origin
* - 2D modes (LASER, DRAG, WJET, WEDM): Corner, center, or bounds-based
* - Belt: Special Y offset handling
* Applies mode-specific origin offsets and updates visual rulers.
*
* @param {boolean} [update_bounds=true] - Whether to recalculate bounds first
*/
function update_origin(update_bounds = true) {
if (update_bounds) { if (update_bounds) {
platform.update_bounds(); platform.update_bounds();
} }
@ -106,7 +139,14 @@ function platformUpdateOrigin(update_bounds = true) {
} }
} }
function platformUpdateSize(updateDark = true) { /**
* Update platform size and visual appearance.
* Sets platform dimensions from device settings, updates grid/rulers,
* and applies dark mode styling if enabled.
*
* @param {boolean} [updateDark=true] - Whether to update dark mode styling
*/
function update_size(updateDark = true) {
const { process, device, controller } = current(); const { process, device, controller } = current();
const { showRulers, units } = controller; const { showRulers, units } = controller;
@ -124,17 +164,18 @@ function platformUpdateSize(updateDark = true) {
gridMinor = unitMM ? 5 : 25.4 / 10; gridMinor = unitMM ? 5 : 25.4 / 10;
if (updateDark) { if (updateDark) {
const scheme = getColorScheme();
if (controller.dark) { if (controller.dark) {
space.platform.set({ light: 0.08 }); space.platform.set({ light: 0.08 });
space.platform.setFont({rulerColor:'#888888'}); space.platform.setFont({rulerColor:'#888888'});
space.platform.setGrid(gridMajor, gridMinor, 0x666666, 0x333333); space.platform.setGrid(gridMajor, gridMinor, scheme.grid.major, scheme.grid.minor);
space.platform.opacity(0.05); space.platform.opacity(0.05);
space.sky.set({ color: 0, ambient: { intensity: 0.6 } }); space.sky.set({ color: 0, ambient: { intensity: 0.6 } });
document.body.classList.add('dark'); document.body.classList.add('dark');
} else { } else {
space.platform.set({ light: 0.08 }); space.platform.set({ light: 0.08 });
space.platform.setFont({rulerColor:'#333333'}); space.platform.setFont({rulerColor:'#333333'});
space.platform.setGrid(gridMajor, gridMinor, 0x999999, 0xcccccc); space.platform.setGrid(gridMajor, gridMinor, scheme.grid.major, scheme.grid.minor);
space.platform.opacity(0.2); space.platform.opacity(0.2);
space.sky.set({ color: 0xffffff, ambient: { intensity: 1.1 } }); space.sky.set({ color: 0xffffff, ambient: { intensity: 1.1 } });
document.body.classList.remove('dark'); document.body.classList.remove('dark');
@ -146,6 +187,10 @@ function platformUpdateSize(updateDark = true) {
platform.update_origin(); platform.update_origin();
} }
/**
* Update topZ to track maximum Z height of all widgets.
* Used for belt mode and other Z-dependent calculations.
*/
function platformUpdateMidZ() { function platformUpdateMidZ() {
topZ = 0; topZ = 0;
api.widgets.each(widget => { api.widgets.each(widget => {
@ -154,7 +199,12 @@ function platformUpdateMidZ() {
space.platform.setMaxZ(topZ); space.platform.setMaxZ(topZ);
} }
function platformUpdateTopZ() { /**
* Update widget Z positioning based on CAM anchoring settings.
* In CAM mode, anchors widgets to top/middle/bottom of stock with offset.
* In other modes, resets topZ to 0.
*/
function update_top_z() {
const { process, stock } = current(); const { process, stock } = current();
const MODE = get_mode(); const MODE = get_mode();
api.widgets.each(widget => { api.widgets.each(widget => {
@ -183,6 +233,13 @@ function platformUpdateTopZ() {
}); });
} }
/**
* Calculate and update stock dimensions for CAM mode.
* Stock dimensions can be absolute or relative (offset from bounds).
* Falls back to offset mode if any stock dimension is 0.
* Calculates stock center point for origin calculations.
* In non-CAM modes, clears stock object.
*/
function platformUpdateStock() { function platformUpdateStock() {
const settings = current(); const settings = current();
const { bounds, process, mode } = settings; const { bounds, process, mode } = settings;
@ -209,12 +266,23 @@ function platformUpdateStock() {
} }
} }
function platformSetBounds(bounds) { /**
* Set explicit platform bounds and trigger update.
* @param {THREE.Box3} bounds - Bounding box to set
* @returns {THREE.Box3} Updated bounds
*/
function set_bounds(bounds) {
setbounds = bounds; setbounds = bounds;
return platformUpdateBounds(); return update_bounds();
} }
function platformUpdateBounds() { /**
* Calculate platform bounds from all widgets or use explicit bounds.
* Unions all widget bounding boxes translated by their positions.
* Updates stock, top Z, midZ, and origin after calculation.
* @returns {THREE.Box3} Calculated or explicit bounds
*/
function update_bounds() {
const bounds = setbounds || new THREE.Box3(); const bounds = setbounds || new THREE.Box3();
if (!setbounds) if (!setbounds)
api.widgets.each(widget => { api.widgets.each(widget => {
@ -231,17 +299,26 @@ function platformUpdateBounds() {
} }
current().bounds = bounds; current().bounds = bounds;
platformUpdateStock(); platformUpdateStock();
platformUpdateTopZ(); update_top_z();
platformUpdateMidZ(); platformUpdateMidZ();
platformUpdateOrigin(false); update_origin(false);
return bounds; return bounds;
} }
function platformSelectedCount() { /**
* Get count of selected widgets in arrange view.
* @returns {number} Selection count (0 if not in arrange view)
*/
function selected_count() {
return api.view.is_arrange() ? api.selection.count() : 0; return api.view.is_arrange() ? api.selection.count() : 0;
} }
function platformUpdateSelected() { /**
* Update visual appearance of selected widgets.
* Sets selected color and highlights extruder buttons for FDM multi-extruder devices.
* Saves widget state after selection.
*/
function update_selected() {
const settings = current(); const settings = current();
const { device } = settings; const { device } = settings;
@ -253,21 +330,32 @@ function platformUpdateSelected() {
let b = $(`sel-ext-${i}`); let b = $(`sel-ext-${i}`);
if (b) b.classList.remove('pop-sel'); if (b) b.classList.remove('pop-sel');
} }
const scheme = getColorScheme();
selection.for_widgets(w => { selection.for_widgets(w => {
w.setColor(COLOR.selected); w.setColor(scheme.widget.selected);
let ext = api.widgets.annotate(w.id).extruder || 0; let ext = api.widgets.annotate(w.id).extruder || 0;
let b = $(`sel-ext-${ext}`); let b = $(`sel-ext-${ext}`);
if (b) b.classList.add('pop-sel'); if (b) b.classList.add('pop-sel');
w.saveState(); w.saveState();
}, true); }, true);
} else { } else {
const scheme = getColorScheme();
selection.for_widgets(w => { selection.for_widgets(w => {
w.setColor(COLOR.selected); w.setColor(scheme.widget.selected);
}, true); }, true);
} }
} }
function platformSelect(widget, shift, recurse = true) { /**
* Select a widget on the platform.
* Only works in arrange view. Handles group selection recursively.
* With shift key, toggles selection. Without shift, replaces selection.
* Emits 'widget.select' event and updates UI.
* @param {Widget} widget - Widget to select
* @param {boolean} shift - Whether shift key is pressed (multi-select)
* @param {boolean} [recurse=true] - Whether to recursively select group members
*/
function select(widget, shift, recurse = true) {
const { event, selection, view } = api; const { event, selection, view } = api;
if (!view.is_arrange()) { if (!view.is_arrange()) {
@ -277,7 +365,7 @@ function platformSelect(widget, shift, recurse = true) {
// apply select to entire group // apply select to entire group
if (recurse && widget && widget.group.length > 1) { if (recurse && widget && widget.group.length > 1) {
for (let w of widget.group) { for (let w of widget.group) {
platformSelect(w, true, false); select(w, true, false);
} }
return; return;
} }
@ -299,7 +387,8 @@ function platformSelect(widget, shift, recurse = true) {
} }
selection.add(widget); selection.add(widget);
event.emit('widget.select', widget); event.emit('widget.select', widget);
widget.setColor(COLOR.selected); const scheme = getColorScheme();
widget.setColor(scheme.widget.selected);
$(`ws-${widget.id}`)?.classList.add('selected'); $(`ws-${widget.id}`)?.classList.add('selected');
selection.update_info(); selection.update_info();
} }
@ -308,7 +397,15 @@ function platformSelect(widget, shift, recurse = true) {
space.update(); space.update();
} }
function platformDeselect(widget, recurse = true) { /**
* Deselect widget(s) on the platform.
* Only works in arrange view. Handles group deselection recursively.
* If no widget provided, deselects all widgets.
* Emits 'widget.deselect' event and updates UI.
* @param {Widget} [widget] - Widget to deselect, or undefined to deselect all
* @param {boolean} [recurse=true] - Whether to recursively deselect group members
*/
function deselect(widget, recurse = true) {
const { selection, view } = api; const { selection, view } = api;
if (!view.is_arrange()) { if (!view.is_arrange()) {
@ -320,7 +417,7 @@ function platformDeselect(widget, recurse = true) {
// apply deselect to entire group // apply deselect to entire group
if (recurse && widget && widget.group.length > 1) { if (recurse && widget && widget.group.length > 1) {
for (let w of widget.group) { for (let w of widget.group) {
platformDeselect(w, false); deselect(w, false);
} }
return; return;
} }
@ -337,13 +434,20 @@ function platformDeselect(widget, recurse = true) {
} }
$(`ws-${widget.id}`)?.classList.remove('selected'); $(`ws-${widget.id}`)?.classList.remove('selected');
widget.setColor(COLOR.deselected); const scheme = getColorScheme();
widget.setColor(scheme.widget.deselected);
platform.update_selected(); platform.update_selected();
selection.update_info(); selection.update_info();
space.update(); space.update();
} }
function platformLoad(url, onload) { /**
* Load mesh from URL (.stl or raw vertex data).
* Detects file type and delegates to load_stl or ajax loader.
* @param {string} url - URL to load from
* @param {Function} [onload] - Callback(vertices, widget) on load complete
*/
function load(url, onload) {
if (url.toLowerCase().indexOf(".stl") > 0) { if (url.toLowerCase().indexOf(".stl") > 0) {
platform.load_stl(url, onload); platform.load_stl(url, onload);
} else { } else {
@ -357,8 +461,16 @@ function platformLoad(url, onload) {
} }
} }
function platformLoadSTL(url, onload, formdata, credentials, headers) { /**
new load.STL().load(url, (vertices, filename) => { * Load STL file from URL and add to platform.
* @param {string} url - URL to STL file
* @param {Function} [onload] - Callback(vertices, widget) on load complete
* @param {FormData} [formdata] - Optional form data for POST request
* @param {boolean} [credentials] - Include credentials in request
* @param {object} [headers] - Additional HTTP headers
*/
function load_stl(url, onload, formdata, credentials, headers) {
new file_load.STL().load(url, (vertices, filename) => {
if (vertices) { if (vertices) {
let widget = newWidget().loadVertices(vertices); let widget = newWidget().loadVertices(vertices);
widget.meta.file = filename; widget.meta.file = filename;
@ -370,9 +482,16 @@ function platformLoadSTL(url, onload, formdata, credentials, headers) {
}, formdata, 1 / api.view.unit_scale(), credentials, headers); }, formdata, 1 / api.view.unit_scale(), credentials, headers);
} }
function platformLoadURL(url, options = {}) { /**
* Load mesh(es) from URL via file loader.
* Supports multiple file formats. Groups loading to prevent intermediate layouts.
* Emits 'load.url' event with loaded widgets.
* @param {string} url - URL to load from
* @param {object} [options={}] - Load options including optional group array
*/
function load_url(url, options = {}) {
platform.group(); platform.group();
load.URL.load(url, options).then(objects => { file_load.URL.load(url, options).then(objects => {
let widgets = []; let widgets = [];
for (let object of objects) { for (let object of objects) {
let widget = newWidget(undefined, options.group).loadVertices(object.mesh); let widget = newWidget(undefined, options.group).loadVertices(object.mesh);
@ -391,12 +510,21 @@ function platformLoadURL(url, options = {}) {
}); });
} }
function platformGroup() { /**
* Begin group loading mode.
* Defers layout and group position updates until group_done() is called.
*/
function group() {
grouping = true; grouping = true;
} }
// called after all new widgets are loaded to update group positions /**
function platformGroupDone(skipLayout) { * Complete group loading and finalize widget positions.
* Called after all widgets in a group are loaded.
* Triggers layout if drop_layout feature is enabled.
* @param {boolean} [skipLayout] - Whether to skip auto-layout
*/
function group_done(skipLayout) {
grouping = false; grouping = false;
Widget.Groups.loadDone(); Widget.Groups.loadDone();
if (api.feature.drop_layout && !skipLayout) { if (api.feature.drop_layout && !skipLayout) {
@ -404,10 +532,24 @@ function platformGroupDone(skipLayout) {
} }
} }
/** Deferred widget additions pending batch processing */
let deferred = []; let deferred = [];
/** Timeout handle for deferred widget batch processing */
let deferTimeout; let deferTimeout;
function platformAdd(widget, shift, nolayout, defer) { /**
* Add widget to platform and 3D scene.
* Can defer addition for batch processing to improve performance.
* Initializes widget annotation (extruder assignment).
* Updates bounds, triggers save, and positions widget if autoLayout is disabled.
* Emits 'widget.add' event.
* @param {Widget} widget - Widget to add
* @param {boolean} shift - Whether to multi-select (shift key pressed)
* @param {boolean} nolayout - Skip layout/positioning
* @param {boolean} defer - Batch multiple adds for better performance
*/
function add(widget, shift, nolayout, defer) {
api.widgets.add(widget); api.widgets.add(widget);
space.world.add(widget.mesh); space.world.add(widget.mesh);
widget.anno = widget.anno || {}; widget.anno = widget.anno || {};
@ -420,13 +562,15 @@ function platformAdd(widget, shift, nolayout, defer) {
platform.select(widget, shift); platform.select(widget, shift);
platform.update_bounds(); platform.update_bounds();
api.space.auto_save(); api.space.auto_save();
platformChanged(); changed();
api.event.emit('widget.add', widget); api.event.emit('widget.add', widget);
// Apply saved visual state AFTER selection/color to avoid being overwritten
api.visuals.apply_visual_state(widget);
if (nolayout) { if (nolayout) {
return; return;
} }
if (!grouping) { if (!grouping) {
platformGroupDone(nolayout); group_done(nolayout);
if (!current().controller.autoLayout) { if (!current().controller.autoLayout) {
positionNewWidget(widget); positionNewWidget(widget);
} }
@ -434,6 +578,12 @@ function platformAdd(widget, shift, nolayout, defer) {
} }
} }
/**
* Process batch of deferred widget additions.
* Called after 150ms timeout to batch multiple rapid additions.
* Positions widgets if autoLayout is disabled, then completes grouping.
* @private
*/
function platformAddDeferred() { function platformAddDeferred() {
let skiplayout = false; let skiplayout = false;
for (let rec of deferred) { for (let rec of deferred) {
@ -445,15 +595,27 @@ function platformAddDeferred() {
} }
} }
if (!grouping) { if (!grouping) {
platformGroupDone(skiplayout); group_done(skiplayout);
} }
api.event.emit('widget.add', deferred.map(r => r.widget)); api.event.emit('widget.add', deferred.map(r => r.widget));
// Apply visual state to all deferred widgets after they're positioned
for (let rec of deferred) {
api.visuals.apply_visual_state(rec.widget);
}
platform.update_bounds(); platform.update_bounds();
api.space.auto_save(); api.space.auto_save();
platformChanged(); changed();
deferred = []; deferred = [];
} }
/**
* Find non-colliding position for newly added widget.
* Uses spiral search pattern radiating from center (0,0).
* Tests 360 positions at each radius (10, 20, 30...200mm).
* Skips if this is the first widget on platform.
* @param {Widget} widget - Widget to position
* @private
*/
function positionNewWidget(widget) { function positionNewWidget(widget) {
if (api.widgets.count() <= 1) { if (api.widgets.count() <= 1) {
return; return;
@ -470,8 +632,8 @@ function positionNewWidget(widget) {
x: dim.x / 2, x: dim.x / 2,
y: dim.y / 2 y: dim.y / 2
}; };
const bounds = base.newBounds(); const bounds = newBounds();
const target = base.newBounds(); const target = newBounds();
const DEG2RAD = Math.PI / 180; const DEG2RAD = Math.PI / 180;
const WIDGETS = api.widgets.all(); const WIDGETS = api.widgets.all();
// look for best position for new widget that doesn't collide // look for best position for new widget that doesn't collide
@ -502,6 +664,15 @@ function positionNewWidget(widget) {
} }
} }
/**
* Delete widget(s) from platform.
* Handles single widget, array of widgets, or widget records.
* Removes from API widget collection, selection, groups, and 3D scene.
* Can defer post-processing for batch deletions.
* Emits 'widget.delete' event.
* @param {Widget|Array<Widget>} widget - Widget(s) to delete
* @param {boolean} [defer] - Skip post-processing (for batch operations)
*/
function platformDelete(widget, defer) { function platformDelete(widget, defer) {
if (!widget) { if (!widget) {
return; return;
@ -511,7 +682,7 @@ function platformDelete(widget, defer) {
for (let i = 0; i < mc.length; i++) { for (let i = 0; i < mc.length; i++) {
platform.delete(mc[i].widget || mc[i], true); platform.delete(mc[i].widget || mc[i], true);
} }
platformDeletePost(); delete_post();
api.event.emit('widget.delete', widget); api.event.emit('widget.delete', widget);
return; return;
} }
@ -521,12 +692,17 @@ function platformDelete(widget, defer) {
Widget.Groups.remove(widget); Widget.Groups.remove(widget);
space.world.remove(widget.mesh); space.world.remove(widget.mesh);
if (!defer) { if (!defer) {
platformDeletePost(); delete_post();
api.event.emit('widget.delete', widget); api.event.emit('widget.delete', widget);
} }
} }
function platformDeletePost() { /**
* Post-deletion cleanup and updates.
* Updates slider, bounds, layout, selection, triggers save.
* @private
*/
function delete_post() {
api.view.update_slider_max(); api.view.update_slider_max();
platform.update_bounds(); platform.update_bounds();
if (get_mode() !== MODES.FDM) { if (get_mode() !== MODES.FDM) {
@ -538,11 +714,15 @@ function platformDeletePost() {
platform.layout(); platform.layout();
} }
api.space.auto_save(); api.space.auto_save();
platformChanged(); changed();
} }
// render list of current widgets /**
function platformChanged() { * Render widget list UI with action buttons.
* Creates interactive widget cards with save/rename/replace/disable/delete buttons.
* Highlights widgets on hover. Updates when widgets are added/removed/modified.
*/
function changed() {
h.bind($('ws-widgets'), api.widgets.all().map(w => { h.bind($('ws-widgets'), api.widgets.all().map(w => {
let color; let color;
let file = w.meta.file || 'no name'; let file = w.meta.file || 'no name';
@ -588,8 +768,9 @@ function platformChanged() {
onclick() { onclick() {
let dis = w.meta.disabled = !w.meta.disabled; let dis = w.meta.disabled = !w.meta.disabled;
let sel = api.selection.contains(w); let sel = api.selection.contains(w);
const scheme = getColorScheme();
api.uc.setClass($(`w-en-${w.id}`), "disabled", dis); api.uc.setClass($(`w-en-${w.id}`), "disabled", dis);
w.setColor(sel ? COLOR.selected : COLOR.deselected, null, false); w.setColor(sel ? scheme.widget.selected : scheme.widget.deselected, null, false);
} }
}, },
[ h.i({ class:"fas fa-ban" }) ] [ h.i({ class:"fas fa-ban" }) ]
@ -619,7 +800,7 @@ function platformChanged() {
// w.setColor(color, null, false); // w.setColor(color, null, false);
// }, // },
onclick() { onclick() {
platformSelect(w, true, false); select(w, true, false);
color = w.getColor(); color = w.getColor();
} }
}) })
@ -628,13 +809,37 @@ function platformChanged() {
})); }));
} }
function platformSelectAll() { /**
* Select all widgets on platform.
* Adds each widget to selection without recursing into groups.
*/
function select_all() {
api.widgets.each(widget => { api.widgets.each(widget => {
platform.select(widget, true, false) platform.select(widget, true, false)
}); });
} }
function platformLayout() { /**
* Arrange widgets on platform using automatic layout.
* Behavior varies by mode:
* - FDM: 2D bin packing with support spacing. Belt mode uses linear Y layout with optional X randomization.
* - SLA: 2D bin packing with support spacing
* - CAM/LASER: 2D bin packing with configurable tile spacing
*
* Belt mode special handling:
* - Positions widgets linearly along Y axis
* - Adds belt lead spacing
* - Optional X randomization for better adhesion
* - Auto-expands bed depth if needed
*
* Standard mode:
* - Uses 2D bin packing algorithm (Packer class)
* - Grows packing area by 10% if widgets don't fit
* - Centers packed layout on platform
*
* Emits 'platform.layout' event when complete.
*/
function layout() {
const MODE = get_mode(); const MODE = get_mode();
const settings = current(); const settings = current();
const { process, device, controller } = settings; const { process, device, controller } = settings;
@ -659,7 +864,7 @@ function platformLayout() {
gap = gap || ((process.sliceSupportExtra || 0) * 2) + 1; gap = gap || ((process.sliceSupportExtra || 0) * 2) + 1;
// auto resize device to support a larger object // auto resize device to support a larger object
if (isBelt) { if (isBelt) {
fitDeviceToWidgets(); fit();
} }
break; break;
} }
@ -686,7 +891,7 @@ function platformLayout() {
if (isBelt) { if (isBelt) {
gap += process.firstLayerBeltLead || 0; gap += process.firstLayerBeltLead || 0;
const WIDGETS = api.widgets.all(); const WIDGETS = api.widgets.all();
let bounds = platformUpdateBounds(), let bounds = update_bounds(),
movey = -(device.bedDepth / 2); movey = -(device.bedDepth / 2);
for (let widget of WIDGETS) { for (let widget of WIDGETS) {
let ylen = widget.track.box.h; let ylen = widget.track.box.h;
@ -737,15 +942,32 @@ function platformLayout() {
api.event.emit('platform.layout'); api.event.emit('platform.layout');
} }
function platformLoadWidget(group, vertices, filename) { /**
* Create widget from vertex data and add to platform.
* Optionally saves to catalog and adds to group.
* @param {Array} [group] - Optional group array for grouping multiple widgets
* @param {Float32Array|Array} vertices - Vertex data (converted to Float32Array if needed)
* @param {string} [filename] - Optional filename for metadata and catalog
* @returns {Widget} Created widget
*/
function load_verts(group, vertices, filename) {
const widget = newWidget(undefined, group).loadVertices(vertices.toFloat32(), true); const widget = newWidget(undefined, group).loadVertices(vertices.toFloat32(), true);
widget.meta.file = filename; widget.meta.file = filename;
if (filename) widget.saveToCatalog(filename); if (filename) widget.saveToCatalog(filename);
platformAdd(widget); add(widget);
return widget; return widget;
} }
function platformLoadFiles(files, group) { /**
* Load multiple files from File objects.
* Supports: STL, OBJ, 3MF, SVG, PNG, JPG, KMZ, Gerber (.gbr), gcode, raw vertex data.
* Also handles settings import (.b64, .km, .ini files).
* Groups loading to prevent intermediate layouts.
* Prompts user for grouping when multiple objects detected.
* @param {Array<File>} files - Array of File objects from file input or drag/drop
* @param {Array} [group] - Optional group array for grouping loaded widgets
*/
function load_files(files, group) {
platform.group(); platform.group();
let loading = files.length; let loading = files.length;
for (let file of files) { for (let file of files) {
@ -773,7 +995,7 @@ function platformLoadFiles(files, group) {
} }
} }
if (israw) { if (israw) {
platformLoadWidget(group, JSON.parse(data)); load_verts(group, JSON.parse(data));
load_dec(); load_dec();
} else if (api.feature.on_load && (isstl || isobj || is3mf)) { } else if (api.feature.on_load && (isstl || isobj || is3mf)) {
api.feature.on_load(data, file); api.feature.on_load(data, file);
@ -782,14 +1004,14 @@ function platformLoadFiles(files, group) {
api.feature.on_add_stl(data, file); api.feature.on_add_stl(data, file);
load_dec(); load_dec();
} else if (isstl) { } else if (isstl) {
const stl = new load.STL().parse(data, api.view.unit_scale()); const stl = new file_load.STL().parse(data, api.view.unit_scale());
platformLoadWidget(group, stl, name); load_verts(group, stl, name);
load_dec(); load_dec();
} else if (isobj) { } else if (isobj) {
const objs = load.OBJ.parse(data.textDecode('utf-8')); const objs = file_load.OBJ.parse(data.textDecode('utf-8'));
const ondn = function() { const ondn = function() {
for (let obj of objs) { for (let obj of objs) {
platformLoadWidget(group, obj, obj.name ? `${obj.name}-${name}` : name); load_verts(group, obj, obj.name ? `${obj.name}-${name}` : name);
} }
load_dec(); load_dec();
}; };
@ -810,7 +1032,7 @@ function platformLoadFiles(files, group) {
api.show.progress(progress, "converting"); api.show.progress(progress, "converting");
}, vertices => { }, vertices => {
api.show.progress(0); api.show.progress(0);
let wid = platformLoadWidget(group, vertices, name); let wid = load_verts(group, vertices, name);
if (api.mode.is_cam()) { if (api.mode.is_cam()) {
// attach raw illustration // attach raw illustration
// api.event.emit('cam.parse.gerber', { data: text, mesh: wid.mesh }); // api.event.emit('cam.parse.gerber', { data: text, mesh: wid.mesh });
@ -821,13 +1043,13 @@ function platformLoadFiles(files, group) {
let odon = function(models) { let odon = function(models) {
let msg = api.show.alert('Adding Objects'); let msg = api.show.alert('Adding Objects');
for (let model of models) { for (let model of models) {
platformLoadWidget(group, model.faces, model.name ? `${model.name}-${name}` : name); load_verts(group, model.faces, model.name ? `${model.name}-${name}` : name);
} }
load_dec(); load_dec();
api.hide.alert(msg); api.hide.alert(msg);
} }
let msg = api.show.alert('Decoding 3MF'); let msg = api.show.alert('Decoding 3MF');
load.TMF.parseAsync(data).then(models => { file_load.TMF.parseAsync(data).then(models => {
api.hide.alert(msg); api.hide.alert(msg);
if (models.length > 1 && !group) { if (models.length > 1 && !group) {
api.uc.confirm(`group ${models.length} objects?`).then(ok => { api.uc.confirm(`group ${models.length} objects?`).then(ok => {
@ -846,14 +1068,14 @@ function platformLoadFiles(files, group) {
} else if (issvg) { } else if (issvg) {
loadSVGDialog(opt => { loadSVGDialog(opt => {
group = group || []; group = group || [];
let svg = load.SVG.parse(data.textDecode('utf-8'), opt); let svg = file_load.SVG.parse(data.textDecode('utf-8'), opt);
let ind = 0; let ind = 0;
if (svg.length === 0) { if (svg.length === 0) {
api.show.alert(`SVG contains no polylines`, 10); api.show.alert(`SVG contains no polylines`, 10);
api.show.alert(`Fonts must be converted to paths`, 10); api.show.alert(`Fonts must be converted to paths`, 10);
} }
for (let v of svg) { for (let v of svg) {
platformLoadWidget(group, svg[ind++], ind ? `${name}-${ind}` : name); load_verts(group, svg[ind++], ind ? `${name}-${ind}` : name);
} }
load_dec(); load_dec();
}); });
@ -869,6 +1091,12 @@ function platformLoadFiles(files, group) {
} }
} }
/**
* Show dialog to configure SVG import settings.
* Prompts for extrusion depth, arc resolution, DPI, and nesting.
* @param {Function} doit - Callback with options: {soup, resolution, segmin, depth, dpi}
* @private
*/
function loadSVGDialog(doit) { function loadSVGDialog(doit) {
const opt = {pre: [ const opt = {pre: [
"<div class='f-col a-center'>", "<div class='f-col a-center'>",
@ -896,7 +1124,13 @@ function loadSVGDialog(doit) {
}); });
} }
function fitDeviceToWidgets() { /**
* Expand platform bed depth to fit widgets (belt mode only).
* Finds maximum Y dimension of all widgets and expands bed if needed.
* Adds 10mm padding. Saves original bed depth.
* @returns {boolean} True if bed was expanded
*/
function fit() {
let maxy = 0; let maxy = 0;
api.widgets.each(widget => { api.widgets.each(widget => {
let wb = widget.mesh.getBoundingBox().clone(); let wb = widget.mesh.getBoundingBox().clone();
@ -919,28 +1153,28 @@ function fitDeviceToWidgets() {
// extend API (api.platform) // extend API (api.platform)
export const platform = { export const platform = {
fit: fitDeviceToWidgets, fit,
add: platformAdd, add,
changed: platformChanged, changed,
delete: platformDelete, delete: platformDelete,
layout: platformLayout, layout: layout,
group: platformGroup, group,
group_done: platformGroupDone, group_done,
load: platformLoad, load,
load_stl: platformLoadSTL, load_stl,
load_url: platformLoadURL, load_url,
load_files: platformLoadFiles, load_files,
load_verts: platformLoadWidget, load_verts,
deselect: platformDeselect, deselect,
select: platformSelect, select,
select_all: platformSelectAll, select_all,
selected_count: platformSelectedCount, selected_count,
set_bounds: platformSetBounds, set_bounds,
update_origin: platformUpdateOrigin, update_origin,
update_bounds: platformUpdateBounds, update_bounds,
update_size: platformUpdateSize, update_size,
update_top_z: platformUpdateTopZ, update_top_z,
update_selected: platformUpdateSelected, update_selected,
update: space.platform.update, update: space.platform.update,
set_font: space.platform.setFont, set_font: space.platform.setFont,
show_axes: space.platform.showAxes, show_axes: space.platform.showAxes,

129
src/kiri/app/preferences.js Normal file
View file

@ -0,0 +1,129 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { $c } from '../../moto/webui.js';
import { api } from './api.js';
import { base } from '../../geo/base.js';
import { space } from '../../moto/space.js';
let { client, platform } = api;
// Lazy initialization to avoid circular dependencies
function getUI() {
return api.ui;
}
function settings() {
return api.conf.get();
}
function unitsSave() {
api.conf.update({ controller: true });
platform.update_size();
}
function aniMeshSave() {
api.conf.update({ controller: true });
api.conf.save();
}
function lineTypeSave() {
const ui = getUI();
const sel = ui.lineType.options[ui.lineType.selectedIndex];
if (sel) {
settings().controller.lineType = sel.value;
api.conf.save();
}
}
function detailSave() {
const ui = getUI();
let level = ui.detail.options[ui.detail.selectedIndex];
if (level) {
level = level.value;
let rez = base.config.clipperClean;
switch (level) {
case '100': rez = 50; break;
case '75': rez = base.config.clipperClean; break;
case '50': rez = 500; break;
case '25': rez = 1000; break;
}
client.config({
base: { clipperClean: rez }
});
settings().controller.detail = level;
api.conf.save();
}
}
function speedSave() {
const ui = getUI();
settings().controller.showSpeeds = ui.showSpeeds.checked;
api.view.update_speeds();
}
function setThreaded(bool) {
if (bool) {
client.pool.start();
} else {
client.pool.stop();
}
return bool;
}
function booleanSave() {
const ui = getUI();
let control = settings().controller;
if (control.assembly != ui.assembly.checked) {
client.wasm(ui.assembly.checked);
}
if (control.antiAlias != ui.antiAlias.checked) {
api.show.alert('Page Reload Required to Change Aliasing');
}
control.antiAlias = ui.antiAlias.checked;
control.assembly = ui.assembly.checked;
control.autoLayout = ui.autoLayout.checked;
control.autoSave = ui.autoSave.checked;
control.dark = ui.dark.checked;
control.devel = ui.devel.checked;
control.drawer = ui.drawer.checked;
control.exportOcto = ui.exportOcto.checked;
control.exportPreview = ui.exportPreview.checked;
control.exportThumb = ui.exportThumb.checked;
control.freeLayout = ui.freeLayout.checked;
control.healMesh = ui.healMesh.checked;
control.ortho = ui.ortho.checked;
control.manifold = ui.manifold.checked;
control.reverseZoom = ui.reverseZoom.checked;
control.scrolls = ui.scrolls.checked;
control.shiny = ui.shiny.checked;
control.showOrigin = ui.showOrigin.checked;
control.showRulers = ui.showRulers.checked;
control.spaceRandoX = ui.spaceRandoX.checked;
// control.threaded = setThreaded(ui.threaded.checked);
control.webGPU = ui.webGPU.checked;
space.view.setZoom(control.reverseZoom, control.zoomSpeed);
// platform.layout();
api.conf.save();
api.platform.update_size();
api.visuals.update_stats();
updateDrawer();
api.event.emit('boolean.update');
space.view.setProjection(control.ortho ? 'orthographic' : 'perspective');
}
function updateDrawer() {
const { drawer, scrolls } = settings().controller;
$c('app', drawer ? 'slideshow' : '', drawer ? '' : 'slideshow');
$c('app', scrolls ? '' : 'hide-scroll', scrolls ? 'hide-scroll' : '');
}
export const preferences = {
unitsSave,
aniMeshSave,
lineTypeSave,
detailSave,
speedSave,
setThreaded,
booleanSave,
updateDrawer
};

602
src/kiri/app/selected.js Normal file
View file

@ -0,0 +1,602 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { api } from './api.js';
import { space } from '../../moto/space.js';
import { THREE } from '../../ext/three.js';
import { tool as MeshTool } from '../../mesh/tool.js';
import { encode as objEncode } from '../../load/obj.js';
import { encode as stlEncode } from '../../load/stl.js';
/**
* Array of currently selected widget meshes.
* @type {Array}
*/
const selectedMeshes = [];
function updateTool(ev) {
api.tool.update(ev);
}
/**
* Execute function for each unique group represented in selection.
* Passes the first widget from each group to the function.
* @param {function} fn - Function(widget) to execute for each group
*/
function for_groups(fn) {
let groups = widgets(true).map(w => w.group).uniq();
for (let group of groups) {
fn(group[0]);
}
}
/**
* Execute function for each selected widget.
* If nothing selected, defaults to all widgets unless noauto=true.
* @param {function} fn - Function(widget) to execute
* @param {boolean} [noauto] - If true, don't auto-select all widgets when none selected
*/
function for_widgets(fn, noauto) {
let m = selectedMeshes;
let w = api.widgets.all();
if (m.length === 0 && w.length) {
m = noauto ? [] : w.map(w => w.mesh);
}
m.slice().forEach(mesh => { fn(mesh.widget) });
}
/**
* Execute function for all widgets with selection status.
* @param {function} fn - Function(widget, is_selected) to execute
*/
function for_status(fn) {
api.widgets.all().forEach(w => {
fn(w, selectedMeshes.contains(w.mesh));
});
}
/**
* Execute function for each selected mesh.
* @param {function} fn - Function(mesh) to execute
*/
function for_meshes(fn) {
selectedMeshes.slice().forEach(mesh => { fn(mesh) });
}
/**
* Move selected widget groups.
* Only works in ARRANGE view.
* @param {number} x - X offset
* @param {number} y - Y offset
* @param {number} z - Z offset
* @param {boolean} [abs] - If true, use absolute positioning instead of relative
*/
function move(x, y, z, abs) {
if (!api.view.is_arrange()) {
return;
}
for_groups(w => w.move(x, y, z, abs));
update_bounds();
api.platform.update_bounds();
api.event.emit('selection.move', {x, y, z, abs});
space.update();
api.space.auto_save();
}
/**
* Compute bounding box of all selected meshes.
* @returns {THREE.Box3} Bounding box
*/
function get_bounds() {
// Helper to compute bounds of selected meshes
const THREE = self.THREE;
let bounds = new THREE.Box3();
selection.for_meshes(mesh => {
let box = mesh.getBoundingBox().clone();
let pos = mesh.widget.mesh.position;
box.min.add(pos);
box.max.add(pos);
bounds = bounds.union(box);
});
return bounds;
}
/**
* Update UI size/scale info fields based on selected mesh bounds.
* Calculates combined bounding box and displays dimensions/scale in UI.
*/
function update_info() {
const ui = api.ui;
let bounds = new THREE.Box3(), track;
for_meshes(mesh => {
let box = mesh.getBoundingBox().clone();
track = mesh.widget.track;
let {pos} = track
box.min.add(pos);
box.max.add(pos);
bounds = bounds.union(box);
});
if (bounds.min.x === Infinity) {
if (selectedMeshes.length === 0) {
ui.size.X.value = 0;
ui.size.Y.value = 0;
ui.size.Z.value = 0;
ui.scale.X.value = 1;
ui.scale.Y.value = 1;
ui.scale.Z.value = 1;
}
return;
}
let dx = bounds.max.x - bounds.min.x,
dy = bounds.max.y - bounds.min.y,
dz = bounds.max.z - bounds.min.z,
scale = api.view.unit_scale();
ui.size.X.value = ui.size.X.was = (dx / scale).round(2)
ui.size.Y.value = ui.size.Y.was = (dy / scale).round(2)
ui.size.Z.value = ui.size.Z.was = (dz / scale).round(2)
ui.scale.X.value = ui.scale.X.was = track.scale.x.round(2);
ui.scale.Y.value = ui.scale.Y.was = track.scale.y.round(2);
ui.scale.Z.value = ui.scale.Z.was = track.scale.z.round(2);
update_bounds();
}
/**
* Update platform bounds and constrain widgets to bed limits.
* For belt mode, fits widgets and updates origin.
* Automatically moves widgets back into bounds if they exceed bed size.
* @param {Array<Widget>} [widgets] - Widgets to bound. Defaults to selected widgets.
*/
function update_bounds(widgets) {
const settings = api.conf.get();
// update bounds on selection for drag limiting
const isBelt = settings.device.bedBelt;
if (isBelt) {
if (api.platform.fit()) {
api.platform.update_origin();
space.update();
}
}
const dvy = settings.device.bedDepth;
const dvx = settings.device.bedWidth;
const bounds_sel = new THREE.Box3();
if (!widgets) {
widgets = selectedMeshes.map(m => m.widget);
}
for (let widget of widgets) {
const wp = widget.track.pos;
const bx = widget.track.box;
const miny = wp.y - bx.h / 2 + dvy / 2;
const maxy = wp.y + bx.h / 2 + dvy / 2;
const minx = wp.x - bx.w / 2 + dvx / 2;
const maxx = wp.x + bx.w / 2 + dvx / 2;
// keep widget in bounds when rotated or scaled
const ylo = miny < 0;
const yhi = !isBelt && maxy > dvy
if (ylo && !yhi) {
widget.move(0, -miny, 0);
} else if (yhi && !ylo) {
widget.move(0, dvy - maxy, 0);
}
const xlo = minx < 0;
const xhi = maxx > dvx;
if (xlo && !xhi) {
widget.move(-minx, 0, 0);
} else if (xhi && !xlo) {
widget.move(dvx - maxx, 0, 0);
}
const wb = widget.mesh.getBoundingBox().clone();
wb.min.x += wp.x;
wb.max.x += wp.x;
wb.min.y += wp.y;
wb.max.y += wp.y;
bounds_sel.union(wb);
}
settings.bounds_sel = bounds_sel;
}
/**
* Duplicate selected widgets with offset.
* Only works in ARRANGE view.
* New widgets offset by bounding box width + 1 in X direction.
*/
function duplicate() {
if (!api.view.is_arrange()) {
return;
}
for_widgets(widget => {
const mesh = widget.mesh;
const bb = mesh.getBoundingBox();
const ow = widget;
const nw = api.widgets.new().loadGeometry(mesh.geometry.clone());
nw.meta.file = ow.meta.file;
nw.meta.vertices = ow.meta.vertices;
nw.anno = ow.annotations();
nw.move(bb.max.x - bb.min.x + 1, 0, 0);
api.platform.add(nw, true);
api.event.emit("widget.duplicate", nw, ow);
});
}
/**
* Mirror selected widgets along X axis.
* Only works in ARRANGE view.
*/
function mirror() {
if (!api.view.is_arrange()) {
return;
}
for_widgets(widget => {
widget.mirror();
});
space.update();
api.space.auto_save();
}
/**
* Scale selected widget groups.
* Only works in ARRANGE view.
* Updates bounds, saves workspace, refreshes info and display.
* @param {number} x - X scale factor
* @param {number} y - Y scale factor
* @param {number} z - Z scale factor
* @param {boolean} [last] - If strictly false, skips auto-save
*/
function scale() {
if (!api.view.is_arrange()) {
return;
}
let args = [...arguments];
for_groups(w => w.scale(...args));
selection.update_bounds();
api.platform.update_bounds();
api.event.emit('selection.scale', args);
// skip update if last argument is strictly 'false'
if (args.last() === false) {
return;
}
api.space.auto_save();
selection.update_info();
space.update();
}
/**
* Rotate selected widget groups.
* Only works in ARRANGE view.
* Updates bounds, saves workspace, refreshes info and display.
* @param {number} x - X rotation in radians
* @param {number} y - Y rotation in radians
* @param {number} z - Z rotation in radians
*/
function rotate(x, y, z) {
if (!api.view.is_arrange()) {
return;
}
for_groups(w => w.rotate(x, y, z));
selection.update_bounds();
api.platform.update_bounds();
api.event.emit('selection.rotate', {x, y, z});
api.space.auto_save();
selection.update_info();
space.update();
}
/**
* Merge selected widgets into a single widget.
* Combines all vertex data from selected widgets into one mesh.
* @param {object} options - Options
* @param {boolean} [options.deleteMerged=true] - If true, deletes original widgets after merge
*/
function merge({ deleteMerged = true }) {
let sel = widgets();
if (sel.length === 0) {
sel = api.widgets.all();
}
let obj = [];
sel.forEach(widget => {
let {x, y, z} = widget.track.pos || { x:0, y:0, z:0 };
let pos = widget.mesh.geometry.attributes.position;
let pvals = pos.array;
for (let i=0, il=pos.count; i<il; i += 3) {
let pi = i * pos.itemSize;
obj.push(pvals[pi++] + x, pvals[pi++] + y, pvals[pi++] + z);
obj.push(pvals[pi++] + x, pvals[pi++] + y, pvals[pi++] + z);
obj.push(pvals[pi++] + x, pvals[pi++] + y, pvals[pi++] + z);
}
});
let w = api.platform.load_verts([], obj, "merged");
if (deleteMerged) api.platform.delete(sel);
api.platform.select(w);
}
/**
* Isolates the bodies within the selected widgets. If no widgets are selected,
* it processes all available widgets. For each widget, it checks if a tool is
* associated; if not, it creates a new MeshTool and indexes the vertices.
* The function identifies isolated bodies within each widget. If a widget has
* a single body, it remains selected. Otherwise, it creates new widgets for
* each isolated body and selects them while deleting the original widget.
*/
function isolateBodies(){
let sel = widgets();
console.log('isolate', sel);
if (sel.length === 0) {
sel = api.widgets.all();
}
for(let widget of sel){
if (!widget.tool) {
let tool = widget.tool = new MeshTool();
tool.index(widget.getGeoVertices({ unroll: true, translate:true }));
}
let bodies = widget.tool.isolateBodies()
if(bodies.length ==1){
api.platform.select(widget);
}else{
// console.log(bodies,Array.from(bodies.entries()))
for(let [i,verts] of bodies.entries()){
let w = api.platform.load_verts([], verts, `isolate ${i} of ${ widget.anno.file}`);
api.platform.select(w)
}
api.platform.delete(widget);
}
}
}
/**
* Export selected widgets to STL or OBJ format.
* If multiple widgets, applies position offsets in STL format.
* @param {string} [format="stl"] - Export format: "stl" or "obj"
* @returns {ArrayBuffer|string} Encoded file data
*/
function exportWidgets(format = "stl") {
let sel = widgets();
if (sel.length === 0) {
sel = api.widgets.all();
}
let facets = 0;
let outs = [];
sel.forEach(widget => {
let mesh = widget.mesh;
let geo = mesh.geometry;
outs.push({geo, widget});
facets += geo.attributes.position.count;
});
if (format === "obj") {
let recs = outs.map(out => {
let meta = out.widget.meta;
let name = meta.file || 'unnamed';
let { position } = out.geo.attributes;
return { file: name, varr: position.array };
});
return objEncode(recs);
}
let recs = outs.map(out => {
let { position } = out.geo.attributes;
let pvals = position.array;
let varr = pvals;
// Apply position offset if exporting multiple widgets
if (outs.length > 1) {
let {x, y, z} = out.widget.track.pos;
varr = new Float32Array(pvals.length);
for (let i = 0; i < pvals.length; i += 3) {
varr[i] = pvals[i] + x;
varr[i+1] = pvals[i+1] + y;
varr[i+2] = pvals[i+2] + z;
}
}
return { file: out.widget.meta.file || 'unnamed', varr };
});
return stlEncode(recs);
}
/**
* Get selected widgets.
* @param {boolean} [orall] - If true and nothing selected, returns all widgets
* @returns {Array<Widget>} Selected widgets or empty array
*/
function widgets(orall) {
let sel = selectedMeshes.slice().map(m => m.widget);
return sel.length ? sel : orall ? api.widgets.all() : []
}
function setDisabled(bool) {
for_widgets(w => w.meta.disabled = bool);
api.platform.update_selected();
}
function settings() {
return api.conf.get();
}
/**
* Prompt user for rotation angles and rotate selection.
* Shows alert if nothing selected.
* Accepts comma-separated X,Y,Z degrees.
*/
function input_rotate() {
if (selection.meshes().length === 0) {
api.show.alert("select object to rotate");
return;
}
api.uc.prompt("Enter X,Y,Z degrees of rotation","").then(coord => {
coord = (coord || '').split(',');
let prod = Math.PI / 180,
x = parseFloat(coord[0] || 0.0) * prod,
y = parseFloat(coord[1] || 0.0) * prod,
z = parseFloat(coord[2] || 0.0) * prod;
selection.rotate(x, y, z);
});
}
/**
* Prompt user for position coordinates and move selection.
* Shows alert if nothing selected.
* Accepts comma-separated X,Y coordinates.
* Adjusts for center vs corner origin modes.
*/
function input_position() {
if (selection.meshes().length === 0) {
api.show.alert("select object to position");
return;
}
let current = settings(),
{ device, process} = current,
center = process.ctOriginCenter || process.camOriginCenter || device.bedRound || device.originCenter,
bounds = selection.get_bounds();
api.uc.prompt("Enter X,Y coordinates for selection","").then(coord => {
coord = (coord || '').split(',');
let x = parseFloat(coord[0] || 0.0),
y = parseFloat(coord[1] || 0.0),
z = parseFloat(coord[2] || 0.0);
if (!center) {
x = x - device.bedWidth/2 + (bounds.max.x - bounds.min.x)/2;
y = y - device.bedDepth/2 + (bounds.max.y - bounds.min.y)/2
}
selection.move(x, y, z, true);
});
}
/**
* Handle size input change event.
* Calculates scale ratio from size change and applies to selection.
* Respects lock checkboxes for proportional scaling.
* Prevents scales below 0.1.
* @param {Event} e - Input change event
* @param {object} ui - UI elements object with size/lock fields
*/
function input_resize(e, ui) {
let dv = parseFloat(e.target.value || 1),
pv = parseFloat(e.target.was || 1),
ra = dv / pv,
xv = parseFloat(ui.size.X.was ?? ui.scale.X.value) || 1,
yv = parseFloat(ui.size.Y.was ?? ui.scale.Y.value) || 1,
zv = parseFloat(ui.size.Z.was ?? ui.scale.Z.value) || 1,
ta = e.target,
xc = ui.lock.X.checked,
yc = ui.lock.Y.checked,
zc = ui.lock.Z.checked,
xt = ta === ui.size.X,
yt = ta === ui.size.Y,
zt = ta === ui.size.Z,
tl = (xt && xc) || (yt && yc) || (zt && zc),
xr = ((tl && xc) || (!tl && xt) ? ra : 1),
yr = ((tl && yc) || (!tl && yt) ? ra : 1),
zr = ((tl && zc) || (!tl && zt) ? ra : 1);
// prevent null scale
if (xv * xr < 0.1 || yv * yr < 0.1 || zv * zr < 0.1) {
api.alerts.show('invalid scale value');
return;
}
selection.scale(xr,yr,zr);
ui.size.X.was = ui.size.X.value = xv * xr;
ui.size.Y.was = ui.size.Y.value = yv * yr;
ui.size.Z.was = ui.size.Z.value = zv * zr;
}
/**
* Handle scale input change event.
* Calculates scale ratio from scale change and applies to selection.
* Respects lock checkboxes for proportional scaling.
* Prevents scales below 0.1.
* @param {Event} e - Input change event
* @param {object} ui - UI elements object with scale/lock fields
*/
function input_scale(e, ui) {
let dv = parseFloat(e.target.value || 1),
pv = parseFloat(e.target.was || 1),
ra = dv / pv,
xv = parseFloat(ui.scale.X.was ?? ui.scale.X.value) || 1,
yv = parseFloat(ui.scale.Y.was ?? ui.scale.Y.value) || 1,
zv = parseFloat(ui.scale.Z.was ?? ui.scale.Y.value) || 1,
ta = e.target,
xc = ui.lock.X.checked,
yc = ui.lock.Y.checked,
zc = ui.lock.Z.checked,
xt = ta === ui.scale.X,
yt = ta === ui.scale.Y,
zt = ta === ui.scale.Z,
tl = (xt && xc) || (yt && yc) || (zt && zc),
xr = ((tl && xc) || (!tl && xt) ? ra : 1),
yr = ((tl && yc) || (!tl && yt) ? ra : 1),
zr = ((tl && zc) || (!tl && zt) ? ra : 1);
// prevent null scale
if (xv * xr < 0.1 || yv * yr < 0.1 || zv * zr < 0.1) {
api.alerts.show('invalid scale value');
return;
}
selection.scale(xr,yr,zr);
ui.scale.X.was = ui.scale.X.value = xv * xr;
ui.scale.Y.was = ui.scale.Y.value = yv * yr;
ui.scale.Z.was = ui.scale.Z.value = zv * zr;
}
function parse_as_float(e) {
e.target.value = parseFloat(e.target.value) || 0;
}
/**
* Bind input handlers to UI elements.
* Sets up Enter key handlers for scale, size, tool, and rotation inputs.
* @param {object} ui - UI elements object
*/
function input_binding(ui) {
// on enter but not on blur
space.event.onEnterKey([
ui.scale.X, (e) => input_scale(e, ui),
ui.scale.Y, (e) => input_scale(e, ui),
ui.scale.Z, (e) => input_scale(e, ui),
ui.size.X, (e) => input_resize(e, ui),
ui.size.Y, (e) => input_resize(e, ui),
ui.size.Z, (e) => input_resize(e, ui),
]);
// on enter and blur
space.event.onEnterKey([
ui.toolName, updateTool,
ui.toolNum, updateTool,
ui.toolFluteDiam, updateTool,
ui.toolFluteLen, updateTool,
ui.toolShaftDiam, updateTool,
ui.toolShaftLen, updateTool,
ui.toolTaperTip, updateTool,
ui.toolTaperAngle, updateTool,
$('rot_x'), parse_as_float,
$('rot_y'), parse_as_float,
$('rot_z'), parse_as_float
], true);
}
// extend API (api.selection)
export const selection = {
duplicate,
for_groups,
for_meshes,
for_status,
for_widgets,
get_bounds,
input_binding,
input_position,
input_rotate,
isolateBodies,
merge,
mirror,
move,
rotate,
scale,
update_bounds,
update_info,
widgets,
export: exportWidgets,
add(w) { selectedMeshes.addOnce(w.mesh) },
delete() { api.platform.delete(widgets()) },
remove(w) { return selectedMeshes.remove(w.mesh) },
count() { return selectedMeshes.length },
contains(w) { return selectedMeshes.indexOf(w.mesh) >= 0 },
enable() { setDisabled(false) },
disable() { setDisabled(true) },
opacity() { api.widgets.opacity(...arguments) },
meshes() { return selectedMeshes.slice() },
};

309
src/kiri/app/slider.js Normal file
View file

@ -0,0 +1,309 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
/**
* Layer Slider Controller (Singleton)
* Manages layer range selection and visualization for slice/preview views
*/
class SliderControl {
#layerLo = 0;
#layerHi = 0;
#layerMax = 0;
#ui;
#slider;
#tracker;
#mobile;
#sliderBar;
#sliderBar2;
#drag = {};
// Callbacks to replace event emissions
#onLayerChange;
#onStackUpdate;
#onSceneUpdate;
#initialized = false;
/**
* Initialize the slider with configuration
* @param {Object} config
* @param {Object} config.ui - UI element references
* @param {HTMLElement} config.tracker - Drag tracker element
* @param {boolean} config.mobile - Mobile device flag
* @param {Function} config.onLayerChange - Called when layer range changes
* @param {Function} config.onStackUpdate - Called to update stack visibility
* @param {Function} config.onSceneUpdate - Called to refresh scene
*/
init({ ui, tracker, mobile, onLayerChange, onStackUpdate, onSceneUpdate }) {
if (this.#initialized) {
throw new Error('Slider already initialized');
}
this.#ui = ui;
this.#slider = ui.slider;
this.#tracker = tracker;
this.#mobile = mobile;
this.#sliderBar = mobile ? 80 : 30;
this.#sliderBar2 = this.#sliderBar * 2;
this.#onLayerChange = onLayerChange;
this.#onStackUpdate = onStackUpdate;
this.#onSceneUpdate = onSceneUpdate;
this.#setupUI();
this.#bindEvents();
this.#initialized = true;
return this;
}
/**
* Get current layer range
* @returns {{lo: number, hi: number, max: number}}
*/
getRange() {
return {
lo: this.#layerLo,
hi: this.#layerHi,
max: this.#layerMax
};
}
/**
* Set layer range (bounds are enforced)
* @param {number} lo - Lower layer
* @param {number} hi - Upper layer
* @param {boolean} notify - Whether to trigger callbacks (default: true)
*/
setRange(lo, hi, notify = true) {
this.#layerLo = Math.max(0, Math.min(hi, lo));
this.#layerHi = Math.max(this.#layerLo, Math.min(this.#layerMax, hi));
this.#updateVisuals();
this.showLabels();
if (notify) {
this.#notifyChange();
}
}
/**
* Set maximum layer count
* @param {number} max - Maximum layer value
*/
setMax(max) {
this.#layerMax = max;
this.#slider.max.innerText = max;
if (max < this.#layerHi) {
this.#layerHi = max;
this.showLabels();
this.#updateVisuals();
}
}
/**
* Update slider visual position from normalized values
* @param {number} startPercent - Start position (0-1)
* @param {number} endPercent - End position (0-1)
*/
updatePosition(startPercent, endPercent) {
const width = this.#slider.range.clientWidth;
const maxval = width - this.#sliderBar2;
const start = Math.max(0, Math.min(1, startPercent));
const end = Math.max(start, Math.min(1, endPercent));
const lowval = start * maxval;
const midval = ((end - start) * maxval) + this.#sliderBar;
const hival = maxval - end * maxval;
this.#slider.hold.style.marginLeft = `${lowval}px`;
this.#slider.mid.style.width = `${midval}px`;
this.#slider.hold.style.marginRight = `${hival}px`;
}
/**
* Show layer number labels
*/
showLabels() {
const digits = this.#layerMax.toString().length;
this.#slider.min.style.width = `${digits}em`;
this.#slider.max.style.width = `${digits}em`;
this.#slider.min.innerText = this.#layerLo;
this.#slider.max.innerText = this.#layerHi;
}
/**
* Show slider UI
*/
show() {
this.#ui.layers.style.display = 'flex';
this.#slider.div.style.display = 'flex';
}
/**
* Hide slider UI
*/
hide() {
this.#ui.layers.style.display = 'none';
this.#slider.div.style.display = 'none';
}
#setupUI() {
if (this.#mobile) {
this.#slider.div.classList.add('slider-mobile');
this.#slider.lo.classList.add('slider-mobile');
this.#slider.hi.classList.add('slider-mobile');
}
}
#bindEvents() {
// Min/Max buttons
this.#slider.min.onclick = () => {
this.#layerLo = 0;
this.#layerHi = 0;
this.showLabels();
this.#updateVisuals();
this.#notifyChange();
};
this.#slider.max.onclick = () => {
this.#layerLo = 0;
this.#layerHi = this.#layerMax;
this.showLabels();
this.#updateVisuals();
this.#notifyChange();
};
// Mouse hover for labels
this.#slider.div.onmouseover = () => {
this.showLabels();
};
this.#slider.div.onmouseleave = (ev) => {
if (!ev.buttons) {
this.hideLabels();
}
};
// Drag handlers for the three slider sections
this.#bindDrag(this.#slider.lo, this.#handleLoDrag.bind(this));
this.#bindDrag(this.#slider.mid, this.#handleMidDrag.bind(this));
this.#bindDrag(this.#slider.hi, this.#handleHiDrag.bind(this));
}
#bindDrag(el, deltaHandler) {
const slider = this.#slider.range;
el.ontouchstart = el.onmousedown = (ev) => {
this.#tracker.style.display = 'block';
ev.stopPropagation();
let obj = (ev.touches ? ev.touches[0] : ev);
this.#drag.width = slider.clientWidth;
this.#drag.maxval = this.#drag.width - this.#sliderBar2;
this.#drag.start = obj.screenX;
this.#drag.loat = this.#drag.low = this.#pxToInt(this.#slider.hold.style.marginLeft);
this.#drag.mdat = this.#drag.mid = this.#slider.mid.clientWidth;
this.#drag.hiat = this.#pxToInt(this.#slider.hold.style.marginRight);
this.#drag.mdmax = this.#drag.width - this.#sliderBar - this.#drag.loat;
this.#drag.himax = this.#drag.width - this.#sliderBar - this.#drag.mdat;
const cancelDrag = this.#tracker.ontouchend = this.#tracker.onmouseup = (ev) => {
if (ev) {
ev.stopPropagation();
ev.preventDefault();
}
slider.onmousemove = undefined;
this.#tracker.style.display = 'none';
};
el.ontouchend = cancelDrag;
el.ontouchmove = this.#tracker.ontouchmove = this.#tracker.onmousemove = (ev) => {
ev.stopPropagation();
ev.preventDefault();
if (ev.buttons === 0) {
return cancelDrag();
}
if (deltaHandler) {
let obj = (ev.touches ? ev.touches[0] : ev);
deltaHandler(obj.screenX - this.#drag.start);
}
};
};
}
#handleLoDrag(delta) {
let midval = this.#drag.mdat - delta;
let lowval = this.#drag.loat + delta;
if (midval < this.#sliderBar || lowval < 0) {
return;
}
this.#slider.hold.style.marginLeft = `${lowval}px`;
this.#slider.mid.style.width = `${midval}px`;
this.#drag.low = lowval;
this.#drag.mid = midval;
this.#updateFromDrag();
}
#handleMidDrag(delta) {
let loval = this.#drag.loat + delta;
let hival = this.#drag.hiat - delta;
if (loval < 0 || hival < 0) {
return;
}
this.#slider.hold.style.marginLeft = `${loval}px`;
this.#slider.hold.style.marginRight = `${hival}px`;
this.#drag.low = loval;
this.#updateFromDrag();
}
#handleHiDrag(delta) {
let midval = this.#drag.mdat + delta;
let hival = this.#drag.hiat - delta;
if (midval < this.#sliderBar || midval > this.#drag.mdmax || hival < 0) {
return;
}
this.#slider.mid.style.width = `${midval}px`;
this.#slider.hold.style.marginRight = `${hival}px`;
this.#drag.mid = midval;
this.#updateFromDrag();
}
#updateFromDrag() {
const start = this.#drag.low / this.#drag.maxval;
const end = (this.#drag.low + this.#drag.mid - this.#sliderBar) / this.#drag.maxval;
this.#layerLo = Math.round(start * this.#layerMax);
this.#layerHi = Math.round(end * this.#layerMax);
this.#notifyChange();
}
#updateVisuals() {
if (this.#layerMax === 0) return;
const start = this.#layerLo / this.#layerMax;
const end = this.#layerHi / this.#layerMax;
this.updatePosition(start, end);
}
#notifyChange() {
// Notify callbacks of layer change
this.#onLayerChange?.(this.#layerHi, this.#layerLo);
this.#onStackUpdate?.(this.#layerLo, this.#layerHi);
this.#onSceneUpdate?.();
}
#pxToInt(txt) {
return txt ? parseInt(txt.substring(0, txt.length - 2)) : 0;
}
hideLabels() {
// Labels hide themselves on mouse leave, but can be called explicitly
}
}
// Export singleton instance
export const slider = new SliderControl();

View file

@ -0,0 +1,215 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
/**
* VisualStateManager: Manages 3-layer visual state hierarchy for widgets
*
* Layer hierarchy (bottom to top):
* 1. Base Layer: User persistent preferences (edges, wireframe, opacity)
* 2. Mode Layer: Mode+view specific defaults (FDM/ARRANGE vs CAM/PREVIEW)
* 3. Operation Layer: Temporary context overrides (paint, selection)
*
* Context types:
* - 'paint': OVERRIDE context (FDM support painting, completely replaces material)
* - 'select': TEMPORARY context (selection highlighting)
* - null: Normal state changes following hierarchy
*/
class VisualStateManager {
constructor(widget, scheme, api) {
this.widget = widget;
this.scheme = scheme;
this.api = api;
// State stack (base → mode → operation)
this.stack = [];
// Active operation states
this.operations = {}; // { context: state }
}
/**
* Load user preferences from localStorage (base layer)
*/
loadPreferences() {
const edges = this.api.local.getBoolean('model.edges');
const wireframe = this.api.local.getBoolean('model.wireframe');
const wireframeColor = this.api.local.getInt('model.wireframe.color') || 0;
const wireframeOpacity = this.api.local.getFloat('model.wireframe.opacity');
this.baseState = {
edges: edges || false,
wireframe: wireframe || false,
wireframeColor: wireframeColor,
wireframeOpacity: wireframeOpacity !== null ? wireframeOpacity : this.scheme.wireframe.opacity,
opacity: 1.0
};
return this.baseState;
}
/**
* Save user preferences to localStorage
*/
savePreferences() {
if (!this.baseState) return;
this.api.local.set('model.edges', this.baseState.edges);
this.api.local.set('model.wireframe', this.baseState.wireframe);
if (this.baseState.wireframeColor !== undefined) {
this.api.local.set('model.wireframe.color', this.baseState.wireframeColor);
}
if (this.baseState.wireframeOpacity !== undefined) {
this.api.local.set('model.wireframe.opacity', this.baseState.wireframeOpacity);
}
}
/**
* Apply mode-specific defaults (mode layer)
* @param {number} mode - Mode ID
* @param {number} viewMode - View mode ID (ARRANGE=1, SLICE=2, PREVIEW=3, ANIMATE=4)
*/
applyModeState(mode, viewMode) {
const viewName = this._viewModeToName(viewMode);
const viewScheme = this.scheme.views[viewName];
this.modeState = {
mode,
viewMode,
opacity: viewScheme?.model_opacity ?? 1.0,
previewOpacity: viewScheme?.preview_opacity,
slicingOpacity: viewScheme?.slicing_opacity,
slicedOpacity: viewScheme?.sliced_opacity
};
return this.modeState;
}
/**
* Push a temporary operation state (operation layer)
* @param {string} context - Operation context ('paint', 'select', etc)
* @param {object} state - State to push
* @param {function} state.restoreCallback - Optional callback to restore state on pop
*/
pushState(context, state) {
if (!context) {
console.warn('pushState called without context');
return;
}
this.operations[context] = state;
this.stack.push({ layer: 'operation', context, state });
return state;
}
/**
* Pop a temporary operation state
* @param {string} context - Operation context to pop
*/
popState(context) {
if (!context) {
console.warn('popState called without context');
return;
}
const operation = this.operations[context];
if (!operation) {
console.warn(`No operation found for context: ${context}`);
return;
}
// Call restore callback if provided
if (operation.restoreCallback) {
operation.restoreCallback();
}
// Remove from operations and stack
delete this.operations[context];
this.stack = this.stack.filter(s => s.context !== context);
}
/**
* Get the current effective state (topmost in hierarchy)
*/
getCurrentState() {
if (this.stack.length > 0) {
return this.stack[this.stack.length - 1].state;
}
if (this.modeState) {
return this.modeState;
}
return this.baseState;
}
/**
* Check if an operation context is currently active
* @param {string} context - Operation context to check
* @returns {boolean}
*/
isOperationActive(context) {
return context in this.operations;
}
/**
* Get all active operation contexts
* @returns {Array<string>}
*/
getActiveContexts() {
return Object.keys(this.operations);
}
/**
* Clear all operation states (e.g., on mode switch)
*/
clearOperations() {
// Call restore callbacks for all active operations
for (const context in this.operations) {
const operation = this.operations[context];
if (operation.restoreCallback) {
operation.restoreCallback();
}
}
this.operations = {};
this.stack = this.stack.filter(s => s.layer !== 'operation');
}
/**
* Get visual state snapshot for undo/redo
*/
getVisualState() {
return {
edges: this.widget.outline ? true : false,
wires: this.widget.wire ? true : false,
opacity: this.widget.getMaterial()?.opacity ?? 1.0
};
}
/**
* Restore visual state from snapshot
* @param {object} state - State snapshot
*/
setVisualState({ edges, wires, opacity }) {
this.widget.cache.vizstate = this.getVisualState();
this.widget._setEdges(edges ?? false);
this.widget._setWireframe(wires ?? false);
if (opacity !== undefined) {
this.widget.setOpacity(opacity);
}
}
/**
* Convert view mode ID to name
* @private
*/
_viewModeToName(viewMode) {
const views = {
1: 'ARRANGE',
2: 'SLICE',
3: 'PREVIEW',
4: 'ANIMATE'
};
return views[viewMode] || 'ARRANGE';
}
}
export { VisualStateManager };

View file

@ -2,7 +2,7 @@
import { local } from '../../data/local.js'; import { local } from '../../data/local.js';
import { broker } from '../../moto/broker.js'; import { broker } from '../../moto/broker.js';
import { js2o, o2js } from './utils.js'; import { js2o, o2js } from '../core/utils.js';
class Stats { class Stats {
constructor(db) { constructor(db) {

112
src/kiri/app/view-mode.js Normal file
View file

@ -0,0 +1,112 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { $ } from '../../moto/webui.js';
import { api } from './api.js';
import { consts } from './consts.js';
import { platform } from './platform.js';
import { selection } from './selected.js';
import { settings } from './conf/manager.js';
import STACKS from './stacks.js';
const { VIEWS } = consts;
const DOC = self.document;
/** Current view mode (ARRANGE, SLICE, PREVIEW, or ANIMATE) */
let viewMode = VIEWS.ARRANGE;
/**
* Set application view mode and update UI accordingly.
* Modes:
* - ARRANGE: Widget positioning and arrangement
* - SLICE: Sliced layer visualization
* - PREVIEW: Toolpath/gcode preview
* - ANIMATE: Animated toolpath playback
*
* Each mode:
* - Updates UI button states
* - Clears selections and bounds
* - Shows/hides relevant tools and sliders
* - Configures widget visibility and rendering
* - Emits 'view.set' event
*
* @param {number} mode - View mode constant from VIEWS
*/
function setViewMode(mode) {
const isCAM = settings.mode() === 'CAM';
viewMode = mode;
platform.deselect();
selection.update_info();
// clear any bounds forced by, for example, WireEDM previews
api.platform.set_bounds();
// disable clear in non-arrange modes
['view-arrange','act-slice','act-preview','act-animate'].forEach(el => {
$(el).classList.remove('selected')
});
$('render-tools').classList.add('hide');
switch (mode) {
case VIEWS.ARRANGE:
$('view-arrange').classList.add('selected');
$('render-tools').classList.remove('hide');
api.function.clear_progress();
api.client.clear();
STACKS.clear();
api.visuals.hide_slider();
api.visuals.update_speeds();
api.visuals.set_visible_layer();
api.visuals.set_widget_visibility(true);
// Restore global visual state (opacity, edges, wireframe)
api.widgets.each(w => w.applyGlobalVisualState());
break;
case VIEWS.SLICE:
$('act-slice').classList.add('selected');
api.visuals.update_speeds();
api.visuals.update_slider_max();
api.visuals.set_widget_visibility(true);
// Hide edges without persisting (preserve user preference)
!isCAM && api.view.set_edges(false, false);
break;
case VIEWS.PREVIEW:
$('act-preview').classList.add('selected');
api.visuals.set_widget_visibility(true);
// Hide edges without persisting (preserve user preference)
!isCAM && api.view.set_edges(false, false);
break;
case VIEWS.ANIMATE:
$('act-animate').classList.add('selected');
// Hide edges without persisting (preserve user preference)
!isCAM && api.view.set_edges(false, false);
break;
default:
console.log("invalid view mode: "+mode);
return;
}
api.event.emit('view.set', mode);
DOC.activeElement.blur();
}
/**
* View mode management API.
* Provides getters/setters and mode checking utilities.
*/
export const view = {
/** Get current view mode */
get() { return viewMode },
/** Set view mode */
set() { setViewMode(...arguments) },
/** Switch to arrange mode */
set_arrange() { setViewMode(VIEWS.ARRANGE) },
/** Switch to slice mode */
set_slice() { setViewMode(VIEWS.SLICE) },
/** Switch to preview mode */
set_preview() { setViewMode(VIEWS.PREVIEW) },
/** Switch to animate mode */
set_animate() { setViewMode(VIEWS.ANIMATE) },
/** Check if in arrange mode */
is_arrange() { return viewMode === VIEWS.ARRANGE },
/** Check if in slice mode */
is_slice() { return viewMode === VIEWS.SLICE },
/** Check if in preview mode */
is_preview() { return viewMode === VIEWS.PREVIEW },
/** Check if in animate mode */
is_animate() { return viewMode === VIEWS.ANIMATE }
};

390
src/kiri/app/visuals.js Normal file
View file

@ -0,0 +1,390 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { api } from './api.js';
import { base } from '../../geo/base.js';
import { consts } from './consts.js';
import { colorSchemeRegistry } from './color/schemes.js';
import { settings } from './conf/manager.js';
import { space } from '../../moto/space.js';
import STACKS from './stacks.js';
const { VIEWS } = consts;
/**
* Get current color scheme
*/
function getColorScheme() {
const mode = api.mode.get_id();
const theme = api.space.is_dark() ? 'dark' : 'light';
return colorSchemeRegistry.getScheme(mode, theme);
}
/**
* Constrain value to min/max bounds.
* @param {number} v - Value to constrain
* @param {number} min - Minimum value
* @param {number} max - Maximum value
* @returns {number} Constrained value
*/
function bound(v,min,max) {
return Math.max(min,Math.min(max,v));
}
/**
* Get unit scale factor for coordinate conversion.
* Returns 25.4 for CAM mode with inches, 1.0 otherwise (mm).
* @returns {number} Unit scale factor
*/
function unitScale() {
return api.mode.is_cam() && settings.ctrl().units === 'in' ? 25.4 : 1;
}
/**
* Update speed color legend for preview mode.
* Shows color-coded speed visualization in preview mode for FDM.
* Generates 20 color swatches from worker and displays in UI.
* Only visible in PREVIEW mode for FDM (not SLA/LASER) when enabled.
* Emits 'preview.speeds' event with min/max speeds.
* @param {number} [maxSpeed] - Maximum speed value
* @param {number} [minSpeed] - Minimum speed value
*/
function updateSpeeds(maxSpeed, minSpeed) {
const { ui } = api;
const viewMode = api.view.get();
ui.speeds.style.display =
maxSpeed &&
settings.mode !== 'SLA' &&
settings.mode !== 'LASER' &&
viewMode === VIEWS.PREVIEW &&
ui.showSpeeds.checked ? 'block' : '';
if (maxSpeed) {
const colors = [];
for (let i = 0; i <= maxSpeed; i += maxSpeed / 20) {
colors.push(Math.round(Math.max(i, 1)));
}
api.client.colors(colors, maxSpeed, speedColors => {
const list = [];
Object.keys(speedColors).map(v => parseInt(v)).sort((a, b) => b - a).forEach(speed => {
const color = speedColors[speed];
const hex = color.toString(16).padStart(6, 0);
const r = (color >> 16) & 0xff;
const g = (color >> 8) & 0xff;
const b = (color >> 0) & 0xff;
const style = `background-color:#${hex}`;
list.push(`<label style="${style}">${speed}</label>`);
});
ui.speedbar.innerHTML = list.join('');
});
api.event.emit('preview.speeds', {
min: minSpeed,
max: maxSpeed
});
}
}
/**
* Update slider visual position and sync settings.
* Updates slider thumb positions based on current range.
* Synchronizes configuration fields from range values.
*/
function updateSlider() {
const { lo, hi, max } = api.slider.getRange();
if (max > 0) {
api.slider.updatePosition(lo / max, hi / max);
}
api.conf.update_fields_from_range();
}
/**
* Set visible layer range and update visualization.
* Bounds values to valid range and updates slider.
* Triggers slice visualization update.
* @param {number} [h] - High layer (defaults to current hi)
* @param {number} [l] - Low layer (defaults to current lo)
*/
function setVisibleLayer(h, l) {
const { lo, hi, max } = api.slider.getRange();
h = h >= 0 ? h : hi;
l = l >= 0 ? l : lo;
const newHi = bound(h, 0, max);
const newLo = bound(l, 0, newHi);
api.slider.setRange(newLo, newHi); // Notify callbacks (STACKS, scene updates)
api.slider.showLabels();
showSlices();
}
/**
* Set global opacity for all widgets.
* Updates material opacity for all widgets and persists to localStorage.
* This is the primary opacity control - all widgets share the same opacity.
* @param {number} value - Opacity value (0.0 to 1.0)
*/
function setOpacity(value) {
api.local.set('model.opacity', value);
// Also update old key for backwards compatibility
api.local.set('model.wireframe.opacity', value);
api.widgets.each(w => {
const mat = w.getMaterial();
mat.opacity = value;
mat.transparent = value < 1.0;
mat.visible = value > 0.0;
});
space.update();
}
/**
* Set wireframe rendering mode for all widgets.
* Updates 3D scene after applying to all widgets.
* Persists state to local storage.
* @param {boolean|object} bool - Enable/disable wireframe, or {toggle: true} to toggle
* @param {number} [color] - Optional wireframe color
* @param {number} [opacity] - Optional wireframe opacity
*/
function setWireframe(bool, color, opacity) {
if (bool && bool.toggle) {
api.local.toggle('model.wireframe');
} else if (typeof bool === 'boolean') {
api.local.set('model.wireframe', bool);
// Persist color and opacity when explicitly setting wireframe
if (color !== undefined) {
api.local.set('model.wireframe.color', color);
}
if (opacity !== undefined) {
api.local.set('model.wireframe.opacity', opacity);
}
}
bool = api.local.getBoolean('model.wireframe');
// Retrieve saved color/opacity if not provided
if (color === undefined) {
color = api.local.getInt('model.wireframe.color') || 0;
}
if (opacity === undefined) {
const saved = api.local.getFloat('model.wireframe.opacity');
opacity = saved !== null ? saved : (api.space.is_dark() ? 0.25 : 0.5);
}
api.widgets.each((w) => { w._setWireframe(bool, color, opacity) });
space.update();
}
/**
* Toggle or set edge rendering for all widgets.
* If bool.toggle is truthy, toggles the current state.
* Otherwise sets edge visibility to bool value.
* Persists state to local storage and updates scene.
* @param {boolean|object} bool - Enable/disable edges, or {toggle: true} to toggle
* @param {boolean} [persist=true] - Whether to save state to local storage
*/
function setEdges(bool, persist = true) {
if (bool && bool.toggle) {
api.local.toggle('model.edges');
bool = api.local.getBoolean('model.edges');
} else {
if (persist) {
api.local.set('model.edges', bool);
}
}
api.widgets.each(w => w._setEdges(bool));
space.update();
}
/**
* Update slider maximum from stack height.
* Sets slider max to tallest stack layer - 1.
* If set=true or current hi exceeds new max, resets range to show top layer.
* @param {boolean} [set] - Force reset range to max
*/
function updateSliderMax(set) {
let max = STACKS.getRange().tallest - 1;
api.slider.setMax(max);
const { hi } = api.slider.getRange();
if (set || max < hi) {
api.slider.setRange(0, max);
api.slider.showLabels();
}
}
/**
* Hide slice visualization and restore widget rendering.
* Clears stack display, restores saved wireframe/opacity state.
*/
function hideSlices() {
STACKS.clear();
// Restore global visual state
api.widgets.each(widget => widget.applyGlobalVisualState());
}
/**
* Show or hide all widgets in 3D scene.
* @param {boolean} bool - True to show, false to hide
*/
function setWidgetVisibility(bool) {
api.widgets.each(w => {
if (bool) {
w.show();
} else {
w.hide();
}
});
}
/**
* Show slice layer visualization.
* Only works in SLICE/PREVIEW/ANIMATE views (not ARRANGE).
* Updates slider range to show specified layer(s).
* If no layer specified, shows current hi layer.
* Expands range downward if needed (newLo = min(layer, lo)).
* Updates slider position and triggers stack visualization.
* @param {number|string} [layer] - Layer to show (defaults to current hi)
*/
function showSlices(layer) {
const viewMode = api.view.get();
if (viewMode === VIEWS.ARRANGE) {
return;
}
showSlider();
const { lo, hi, max } = api.slider.getRange();
if (typeof(layer) === 'string' || typeof(layer) === 'number') {
layer = parseInt(layer);
} else {
layer = hi;
}
layer = bound(layer, 0, max);
const newLo = layer < lo ? layer : lo;
api.slider.setRange(newLo, layer);
api.slider.showLabels();
updateSlider();
// STACKS.setRange is called by slider callback
space.update();
}
/**
* Show layer slider UI element.
*/
function showSlider() {
api.slider.show();
}
/**
* Hide layer slider and speed legend UI elements.
*/
function hideSlider() {
api.slider.hide();
api.ui.speeds.style.display = 'none';
}
/**
* Synchronize stack label visibility with saved preferences.
* Reads visibility state from settings.labels for current mode/view combination.
* Applies visibility to all stack labels.
*/
function updateStackLabelState() {
const settings = api.conf.get();
// match label checkboxes to preference
for (let label of api.stacks.getLabels()) {
let check = `${settings.mode}-${api.view.get()}-${label}`;
api.stacks.setVisible(label, settings.labels[check] !== false);
}
}
/**
* Apply saved visual state to a widget.
* Delegates to widget's applyGlobalVisualState method.
* Only applies in ARRANGE view mode.
* @param {Widget} widget - Widget to apply visual state to
*/
function applyVisualState(widget) {
const viewMode = api.view.get();
// Only apply visual state in ARRANGE mode
if (viewMode !== VIEWS.ARRANGE) {
return;
}
widget.applyGlobalVisualState();
}
/**
* Update progress bar display.
* In debug mode, also shows progress status message.
* @param {number} [value=0] - Progress value (0.0 to 1.0)
* @param {string} [msg] - Optional status message to display
*/
function setProgress(value = 0, msg) {
value = (value * 100).round(4);
api.ui.progress.width = value+'%';
if (self.debug) {
// console.log(msg, value.round(2));
api.ui.prostatus.style.display = 'flex';
if (msg) {
api.ui.prostatus.innerHTML = msg;
} else {
api.ui.prostatus.innerHTML = '';
}
}
}
let statsTimer;
let statsAdd;
function updateStats(add) {
statsAdd = add ? { ...add, 'hr': 1 } : {};
clearTimeout(statsTimer);
let { div, fps, rms, rnfo } = api.ui.stats;
if (api.devel.enabled === false) {
fps.innerText = '';
rms.innerText = '';
rnfo.innerHTML = '';
return;
}
div.style.display = 'flex';
statsTimer = setInterval(() => {
const nrms = space.view.getRMS().toFixed(1);
const nfps = space.view.getFPS().toFixed(1);
const rend = space.renderInfo();
const { memory, render } = rend;
if (nfps !== fps.innerText) {
fps.innerText = nfps;
}
if (nrms !== rms.innerText) {
rms.innerText = nrms;
}
if (rnfo.offsetParent !== null) {
rnfo.innerHTML = Object.entries({
...statsAdd,
...memory,
...render,
render_ms: nrms,
frames_sec: nfps
}).map(row => {
return row[0] === 'hr' ?
'<hr><hr>' :
`<div>${row[0]}</div><label>${base.util.comma(row[1])}</label>` ;
}).join('');
}
}, 100);
}
export const visuals = {
apply_visual_state: applyVisualState,
hide_slices: hideSlices,
hide_slider: hideSlider,
set_edges: setEdges,
set_opacity: setOpacity,
set_progress: setProgress,
set_visible_layer: setVisibleLayer,
set_widget_visibility: setWidgetVisibility,
set_wireframe: setWireframe,
show_slices: showSlices,
show_slider: showSlider,
unit_scale: unitScale,
update_slider_max: updateSliderMax,
update_slider: updateSlider,
update_speeds: updateSpeeds,
update_stack_labels: updateStackLabelState,
update_stats: updateStats
};

404
src/kiri/app/widget.js Normal file
View file

@ -0,0 +1,404 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { Widget as WidgetCore } from '../core/widget.js';
import { util } from '../../geo/base.js';
import { avgc } from '../core/utils.js';
import { colorSchemeRegistry } from './color/schemes.js';
import { VisualStateManager } from './state/visual.js';
const solid_opacity = 1.0;
function catalog() { return self.kiri_catalog };
function index() { return self.kiri_catalog.index }
/**
* UI-enhanced Widget subclass for main thread
* Adds rendering, persistence, and visual state management
*/
class Widget extends WidgetCore {
constructor(id, group) {
super(id, group);
// Visual state manager will be initialized when api is available
this.visualStateManager = null;
}
/**
* Initialize visual state manager (called when api is available)
* @private
*/
_initVisualStateManager() {
if (this.visualStateManager || !this.api) {
return;
}
const mode = this.api.mode.get_id();
const theme = this.api.space.is_dark() ? 'dark' : 'light';
const scheme = colorSchemeRegistry.getScheme(mode, theme);
this.visualStateManager = new VisualStateManager(this, scheme, this.api);
this.visualStateManager.loadPreferences();
}
/**
* Get current color scheme
* @private
*/
_getScheme() {
if (!this.api) return null;
const mode = this.api.mode.get_id();
const theme = this.api.space.is_dark() ? 'dark' : 'light';
return colorSchemeRegistry.getScheme(mode, theme);
}
/**
* Push a temporary visual state (for operations like paint)
*/
pushVisualState(context, state) {
this._initVisualStateManager();
if (this.visualStateManager) {
return this.visualStateManager.pushState(context, state);
}
}
/**
* Pop a temporary visual state
*/
popVisualState(context) {
if (this.visualStateManager) {
this.visualStateManager.popState(context);
}
}
/**
* Save widget geometry to catalog
*/
saveToCatalog(filename, overwrite) {
if (!filename) {
filename = this.meta.file;
}
if (this.grouped && !overwrite) {
return this;
}
const widget = this;
const mark = Date.now();
const vertices = widget.getGeoVertices({ unroll: true }).slice();
widget.meta.file = filename;
widget.meta.save = mark;
catalog().putFile(filename, vertices, () => {
console.log("saved mesh ["+(vertices.length/3)+"] time ["+(Date.now()-mark)+"]");
});
return this;
}
/**
* Save widget state to index
*/
saveState(ondone) {
if (!ondone) {
clearTimeout(this._save_timer);
this._save_timer = setTimeout(() => {
this._save_timer = undefined;
this.saveState(() => {});
}, 1500);
return;
}
const widget = this;
index().put('ws-save-'+this.id, {
anno: this.annotations(),
geo: widget.getGeoVertices({ unroll: false }).slice(),
group: this.group.id,
meta: this.meta,
track: widget.track
}, result => {
widget.meta.saved = Date.now();
if (ondone) ondone();
});
}
/**
* Set mesh material color
*/
setColor(color, settings, save = true) {
if (settings) {
console.trace('legacy call with settings');
}
if (Array.isArray(color)) {
color = color[this.getExtruder() % color.length];
}
if (save) {
this.color = color;
}
let material = this.getMaterial();
material.color.set(this.meta.disabled ? avgc(0x888888, color, 3) : color);
}
/**
* Get current visual state
*/
getVisualState() {
return {
edges: this.outline ? true : false,
wires: this.wire ? true : false,
opacity: this.getMaterial().opacity
};
}
/**
* Set visual state
*/
setVisualState({ edges, wires, opacity}) {
this.cache.vizstate = this.getVisualState();
this._setEdges(edges ?? false);
this._setWireframe(wires ?? false);
// Only set opacity if explicitly provided (don't default to 1)
if (opacity !== undefined) {
this.setOpacity(opacity);
}
}
/**
* Refresh visual state
*/
refreshVisualState() {
this.setVisualState(this.getVisualState());
}
/**
* Restore previous visual state
*/
restoreVisualState() {
if (this.cache.vizstate) {
this.setVisualState(this.cache.vizstate);
}
}
/**
* Apply global visual state from localStorage to this widget.
* Reads opacity, edges, and wireframe settings from global config.
* This is the primary method for syncing widget appearance with global preferences.
*/
applyGlobalVisualState() {
if (!this.api) {
return;
}
const material = this.getMaterial();
// Apply global opacity
const opacity = this.api.local.getFloat('model.opacity') ?? 1.0;
material.opacity = opacity;
material.transparent = opacity < 1.0;
material.visible = opacity > 0.0;
// Apply global edges
const edges = this.api.local.getBoolean('model.edges', false);
this._setEdges(edges);
// Apply global wireframe
const wireframe = this.api.local.getBoolean('model.wireframe', false);
if (wireframe) {
const color = this.api.local.getInt('model.wireframe.color') || 0;
const wfOpacity = this.api.local.getFloat('model.wireframe.opacity') ?? 0.5;
this._setWireframe(true, color, wfOpacity);
} else {
this._setWireframe(false);
}
}
/**
* Set material opacity
*/
setOpacity(value) {
const mat = this.getMaterial();
if (value <= 0.0) {
mat.transparent = solid_opacity < 1.0;
mat.opacity = solid_opacity;
mat.visible = false;
} else if (util.inRange(value, 0.0, solid_opacity)) {
mat.transparent = value < 1.0;
mat.opacity = value;
mat.visible = true;
}
}
/**
* Set edge rendering - public interface for core widget rotation/scale
* Delegates to internal _setEdges method
*/
setEdges(set) {
this._setEdges(set);
}
/**
* Set edge rendering (internal method - use api.view.set_edges for global control)
* @private
*/
_setEdges(set) {
this.__setEdges(set);
}
__setEdges(set) {
if (!(this.api && this.api.conf)) {
// missing api features in engine mode
return;
}
const mesh = this.mesh;
// Handle toggle
if (set && set.toggle) {
set = this.outline ? false : true;
}
// Remove outline (but keep cache)
if (this.outline) {
mesh.remove(this.outline);
this.outline = null;
}
if (!set) {
return; // Just hide, keep cache
}
// Resolve current angle and color
const scheme = this._getScheme();
const angle = scheme?.edges?.angle ?? this.api.conf.get().controller.edgeangle ?? 20;
const color = scheme?.edges?.color ?? 0x888888;
// Initialize cache if needed
if (!this.cache.edges) {
this.cache.edges = { geometry: null, angle: null, version: 0 };
}
const cache = this.cache.edges;
let edgesGeometry;
// Check if we can reuse cached geometry
if (cache.geometry &&
cache.angle === angle &&
cache.version === this.geomVersion) {
// Reuse cached geometry
edgesGeometry = cache.geometry;
} else {
// Regenerate edges
edgesGeometry = new THREE.EdgesGeometry(mesh.geometry, angle);
// Update cache
cache.geometry = edgesGeometry;
cache.angle = angle;
cache.version = this.geomVersion;
}
// Create outline with cached/new geometry
const material = new THREE.LineBasicMaterial({ color });
this.outline = new THREE.LineSegments(edgesGeometry, material);
this.outline.renderOrder = -20;
mesh.add(this.outline);
}
/**
* Set wireframe rendering - public interface for core widget rotation/scale
* Delegates to internal _setWireframe method
*/
setWireframe(set, color, opacity) {
this._setWireframe(set, color, opacity);
}
/**
* Set wireframe rendering (internal method - use api.view.set_wireframe for global control)
* @private
*/
_setWireframe(set, color, opacity) {
if (!(this.api && this.api.conf)) {
// missing api features in engine mode
return;
}
let mesh = this.mesh,
widget = this;
// Save current opacity before removing wire
const currentOpacity = this.getMaterial().opacity;
if (this.wire) {
this.setOpacity(solid_opacity);
mesh.remove(this.wire);
this.wire = null;
}
if (set) {
const scheme = this._getScheme();
const wireColor = scheme?.wireframe?.color ?? 0xaaaaaa;
const wireOpacity = scheme?.wireframe?.opacity ?? 0.5;
const mat = new THREE.MeshBasicMaterial({
wireframe: true,
color: wireColor,
opacity: wireOpacity,
transparent: true
});
const wire = widget.wire = new THREE.Mesh(mesh.geometry.shallowClone(), mat);
mesh.add(wire);
}
if (this.api.view.is_arrange()) {
this.setColor(this.color);
} else {
this.setColor(0x888888,undefined,false);
}
if (opacity !== undefined) {
widget.setOpacity(opacity);
} else if (!set && this.wire === null) {
// Restore original opacity when removing wireframe without explicit opacity
widget.setOpacity(currentOpacity);
}
}
/**
* Load widget from catalog
*/
static loadFromCatalog(filename, ondone) {
catalog().getFile(filename, function(data) {
let widget = new Widget().loadVertices(data);
widget.meta.file = filename;
ondone(widget);
});
}
/**
* Load widget from saved state
*/
static loadFromState(id, ondone, move) {
const Group = Widget.Groups;
index().get('ws-save-'+id, function(data) {
if (data) {
let vertices = data.geo || data,
track = data.track || undefined,
group = data.group || id,
anno = data.anno || undefined,
widget = new Widget(id, Group.forid(group)),
meta = data.meta || widget.meta,
ptr = widget.loadVertices(vertices);
widget.meta = meta;
widget.anno = anno || widget.anno;
// restore widget position if specified
if (move && track && track.pos) {
widget.track = track;
widget.move(track.pos.x, track.pos.y, track.pos.z, true);
}
ondone(ptr);
} else {
ondone(null);
}
});
}
/**
* Delete widget from saved state
*/
static deleteFromState(id,ondone) {
index().remove('ws-save-'+id, ondone);
}
}
/**
* Factory function to create UI-enhanced widgets
*/
function newWidget(id, group) {
return new Widget(id, group);
}
export { Widget, newWidget };

View file

@ -1,9 +1,9 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */ /** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { $ } from '../../moto/webui.js'; import { $ } from '../../moto/webui.js';
import { api } from './api.js'; import { api } from '../app/api.js';
import { load } from '../../load/file.js'; import { load } from '../../load/file.js';
import { Widget, newWidget } from './widget.js'; import { Widget, newWidget } from '../app/widget.js';
let WIDGETS = []; let WIDGETS = [];
@ -105,9 +105,9 @@ export const widgets = {
replace, replace,
opacity(o) { setOpacity(o); console.trace('opacity() deprecated') }, opacity(o) { setOpacity(o); console.trace('opacity() deprecated') },
annotate, annotate,
setIndexed,
setAxisIndex, setAxisIndex,
setColor(c,s) { api.widgets.each(widget => widget.setColor(c,undefined,s) )}, setColor(c,s) { api.widgets.each(widget => widget.setColor(c,undefined,s) )},
setIndexed,
setOpacity, setOpacity,
all() { return WIDGETS.slice() }, all() { return WIDGETS.slice() },
add(w) { return WIDGETS.push(w) }, add(w) { return WIDGETS.push(w) },

View file

@ -1,10 +1,12 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */ /** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { api } from './api.js'; import { api } from './api.js';
import { noop } from './utils.js'; import { noop } from '../core/utils.js';
import { codec } from './codec.js'; import { codec } from '../core/codec.js';
import { Widget } from './widget.js'; import { Widget } from './widget.js';
import { newPrint } from './print.js'; import { newPrint } from '../core/print.js';
const WORKPATH = "../lib/kiri/run/worker.js";
// this code runs in kiri's main loop // this code runs in kiri's main loop
let debug = self.debug === true, let debug = self.debug === true,
@ -19,7 +21,7 @@ let debug = self.debug === true,
poolpath; poolpath;
/** /**
* @param {Function} fn name of function in kiri.worker * @param {Function} fn name of function in worker
* @param {Object} data to send to server * @param {Object} data to send to server
* @param {Function} onreply function to call on reply messages * @param {Function} onreply function to call on reply messages
* @param {Object[]} zerocopy array of objects to pass using zerocopy * @param {Object[]} zerocopy array of objects to pass using zerocopy
@ -31,14 +33,17 @@ function send(fn, data, onreply, zerocopy) {
// track it only when we expect and can handle a reply // track it only when we expect and can handle a reply
running[seq] = { fn:onreply }; running[seq] = { fn:onreply };
} }
let msg = { let msg = {
seq: seq, seq: seq,
task: fn, task: fn,
time: time(), time: time(),
data: data data: data
}; };
// console.log('client send', msg); // console.log('client send', msg);
// if (!fn) { console.trace({empty_fn: data}) }; // if (!fn) { console.trace({empty_fn: data}) };
try { try {
worker.postMessage(msg, zerocopy); worker.postMessage(msg, zerocopy);
} catch (error) { } catch (error) {
@ -46,9 +51,18 @@ function send(fn, data, onreply, zerocopy) {
} }
} }
// Promise wrapper around send()
function sendp(fn, data, zerocopy) {
return new Promise(resolve => {
send(fn, data, resolve, zerocopy);
});
}
// code is running in the browser / client context // code is running in the browser / client context
export const client = { export const client = {
send: send, send,
sendp,
setWorkPath(path) { setWorkPath(path) {
workpath = path; workpath = path;
@ -80,7 +94,7 @@ export const client = {
const blob = new Blob([ work ], { type: 'application/javascript' }); const blob = new Blob([ work ], { type: 'application/javascript' });
return new Worker(URL.createObjectURL(blob)); return new Worker(URL.createObjectURL(blob));
} else { } else {
let worker = new Worker(workpath || api.const.PATHS.work, { type: 'module' }); let worker = new Worker(workpath || WORKPATH, { type: 'module' });
worker.onerror = (error) => { worker.onerror = (error) => {
console.log({ WORKER_ERROR: error }); console.log({ WORKER_ERROR: error });
error.preventDefault(); error.preventDefault();
@ -186,11 +200,12 @@ export const client = {
let vertices = widget.getGeoVertices(); let vertices = widget.getGeoVertices();
send("sync", { send("sync", {
id: widget.id, id: widget.id,
meta: widget.meta, anno: widget.annotations(),
group: widget.group.id, group: widget.group.id,
meta: widget.meta,
position: widget.mesh.position,
track: widget.track, track: widget.track,
vertices, vertices,
position: widget.mesh.position,
}, done => { }, done => {
widget.modified = false; widget.modified = false;
}); });
@ -211,11 +226,17 @@ export const client = {
}); });
}, },
// called once at the beginning of slice operations
// setup any global shared state
slicePre(settings, callback) {
send("slicePre", { settings }, callback);
},
// called once for each widget // called once for each widget
slice(settings, widget, callback) { slice(settings, widget, callback) {
send("slice", { send("slice", {
id: widget.id, id: widget.id,
anno: widget.annotations(),
settings: settings settings: settings
}, reply => { }, reply => {
callback(reply); callback(reply);
@ -230,8 +251,8 @@ export const client = {
// called once after each widget is sliced // called once after each widget is sliced
// performs merged actions that consider all widget slice data // performs merged actions that consider all widget slice data
sliceAll(settings, callback) { slicePost(settings, callback) {
send("sliceAll", { settings }, callback); send("slicePost", { settings }, callback);
}, },
// turn widget slices into unified path output // turn widget slices into unified path output
@ -262,7 +283,7 @@ export const client = {
ondone(null, reply.error); ondone(null, reply.error);
} }
if (reply.debug) { if (reply.debug) {
api.event.emit("export.debug", reply.debug); console.log('worker debug', reply.debug);
} }
}); });
}, },

234
src/kiri/app/workspace.js Normal file
View file

@ -0,0 +1,234 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { THREE } from '../../ext/three.js';
import { api } from './api.js';
import { local as SDB } from '../../data/local.js';
import { openFiles } from './files.js';
import { platform } from './platform.js';
import { selection } from './selected.js';
import { settings } from './conf/manager.js';
import { space as SPACE } from '../../moto/space.js';
import { utils } from '../core/utils.js';
import { Widget } from './widget.js';
import { Index } from '../../data/index.js';
const { o2js, js2o, ls2o, parseOpt } = utils;
const LOC = self.location;
const SETUP = parseOpt(LOC.search.substring(1));
const FILES = openFiles(new Index(SETUP.d ? SETUP.d[0] : 'kiri'));
/** Auto-save timer handle */
let autoSaveTimer = null;
/**
* Reload the application page.
* Emits 'reload' event and defers reload until busy count reaches zero.
*/
function reload() {
api.event.emit('reload');
do_reload(100);
}
/**
* Perform page reload after ensuring async operations complete.
* Checks busy state and defers reload until idle.
* Retries with exponential backoff (100ms -> 250ms).
* @param {number} [time=100] - Initial delay in milliseconds
* @private
*/
function do_reload(time) {
// allow time for async saves to complete and busy to to to zero
setTimeout(() => {
if (api.busy.val() === 0) {
LOC.reload();
} else {
console.log(`reload deferred on busy=${api.busy.val()}`);
do_reload(250);
}
}, time || 100);
}
/**
* Trigger delayed auto-save if enabled.
* Debounces saves with 1-second delay.
* Only saves if controller.autoSave is enabled.
*/
function auto_save() {
if (!settings.ctrl().autoSave) {
return;
}
clearTimeout(autoSaveTimer);
autoSaveTimer = setTimeout(() => {
api.space.save(true);
}, 1000);
}
/**
* Set camera focus on widgets or point.
* If point provided, focuses on that point.
* Otherwise calculates centroid of selection (or all widgets if sel undefined/empty).
* For CAM indexed mode, uses Z=0 focus height. Otherwise uses half of top_z.
* @param {Widget|Array<Widget>} [sel] - Widget(s) to focus on (defaults to all)
* @param {object} [point] - Optional point {x, y, z} to focus on directly
*/
function set_focus(sel, point) {
if (point) {
SPACE.platform.setCenter(point.x, point.z, point.y);
SPACE.view.setFocus(new THREE.Vector3(point.x, point.y, point.z));
return;
}
if (sel === undefined) {
sel = api.widgets.all();
} else if (!Array.isArray) {
sel = [ sel ];
} else if (sel.length === 0) {
sel = api.widgets.all();
}
let pos = { x:0, y:0, z:0 };
for (let widget of sel) {
pos.x += widget.track.pos.x;
pos.y += widget.track.pos.y;
pos.z += widget.track.pos.z;
}
if (sel.length) {
pos.x /= sel.length;
pos.y /= sel.length;
pos.z /= sel.length;
}
let cam_index = api.conf.get().process.camStockIndexed || false;
let focus_z = cam_index ? 0 : platform.top_z() / 2;
SPACE.platform.setCenter(pos.x, -pos.y, focus_z);
SPACE.view.setFocus(new THREE.Vector3(pos.x, focus_z, -pos.y));
}
/**
* Save workspace state to local storage.
* Saves:
* - Configuration settings
* - Widget list and metadata
* - Widget positions and states
* Removes stale widget data for deleted widgets.
* Shows alert unless quiet=true.
* @param {boolean} [quiet] - Suppress "workspace saved" alert
*/
function save(quiet) {
api.conf.save();
const newWidgets = [];
const oldWidgets = js2o(SDB.getItem('ws-widgets'), []);
api.widgets.each(widget => {
if (widget.synth) return;
newWidgets.push(widget.id);
oldWidgets.remove(widget.id);
widget.saveState();
let ann = api.widgets.annotate(widget.id);
ann.file = widget.meta.file;
ann.url = widget.meta.url;
});
SDB.setItem('ws-widgets', o2js(newWidgets));
oldWidgets.forEach(wid => {
Widget.deleteFromState(wid);
});
// eliminate dangling saved widgets
FILES.deleteFilter(key => newWidgets.indexOf(key.substring(8)) < 0, "ws-save-", "ws-savf");
if (!quiet) {
api.show.alert("workspace saved", 1);
}
}
/**
* Restore workspace from local storage.
* Restores:
* - Configuration settings
* - Camera view position
* - All saved widgets from storage
* Clears existing platform widgets unless skip_widget_load=true.
* Calls ondone callback when complete.
* @param {Function} [ondone] - Callback when restore completes
* @param {boolean} [skip_widget_load] - Skip loading widgets (config only)
* @returns {boolean} True if widgets were queued for loading
*/
function restore(ondone, skip_widget_load) {
let newset = api.conf.restore(false),
camera = newset.controller.view,
toload = ls2o('ws-widgets',[]),
loaded = 0,
position = true;
api.conf.update_fields();
platform.update_size();
SPACE.view.reset();
if (camera) {
SPACE.view.load(camera);
} else {
SPACE.view.home();
}
if (skip_widget_load) {
ondone && ondone();
return;
}
// remove any widgets from platform
api.widgets.each(function(widget) {
platform.delete(widget);
});
// load any widget by name that was saved to the workspace
// after the last widget is loaded, call ondone()
for (let widgetid of toload) {
Widget.loadFromState(widgetid, (widget) => {
if (widget) {
platform.add(widget, 0, position, true);
let ann = api.widgets.annotate(widgetid);
widget.meta.file = ann.file;
widget.meta.url = ann.url;
}
if (++loaded === toload.length) {
platform.deselect();
if (ondone) {
ondone();
setTimeout(() => {
platform.update_bounds();
SPACE.update();
}, 1);
}
}
}, position);
};
// when there is nothing async to load, call ondone()
if (toload.length === 0 && ondone) {
ondone();
}
}
/**
* Clear all widgets from workspace.
* Clears worker cache/memory, selects all widgets, and deletes them.
*/
function clear() {
// free up worker cache/mem
api.client.clear();
platform.select_all();
platform.delete(selection.meshes());
}
/**
* Check if dark mode is enabled.
* @returns {boolean} True if dark mode active
*/
function is_dark() {
return settings.ctrl().dark;
}
export const workspace = {
auto_save,
clear,
is_dark,
reload,
restore,
save,
set_focus,
update: SPACE.update,
};

View file

@ -1,200 +0,0 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import alerts from './alerts.js';
import settings from './settings.js'
import STACKS from './stacks.js';
import { broker } from '../../moto/broker.js';
import { client as work } from './client.js';
import { consts, COLOR as color, LISTS as lists } from './consts.js';
import { device, devices } from './devices.js';
import { catalog, dialog, event, group, help, hide, image } from './main.js';
import { modal, mode, process, show, space, util, view } from './main.js';
import { local as dataLocal } from '../../data/local.js';
import { noop, ajax, o2js, js2o } from './utils.js';
import { functions } from './function.js';
import { platform } from './platform.js';
import { selection } from './selection.js';
import { stats } from './stats.js';
import { newWidget } from './widget.js';
import { widgets } from './widgets.js';
import { updateTool } from '../mode/cam/tools.js';
import { beta, version } from '../../moto/license.js';
import { space as SPACE } from '../../moto/space.js';
import { types as load } from '../../load/file.js';
import { LANG } from './lang.js';
import { LOCAL, SETUP, SECURE } from './main.js';
import { UI } from './ui.js';
import web from '../../moto/webui.js';
let UC = UI.prefix('kiri').inputAction(settings.conf.update),
und = undefined,
clone = Object.clone,
isHover = false,
feature = {
seed: true, // seed profiles on first use
meta: true, // show selected widget metadata
frame: true, // receive frame events
alert_event: false, // emit alerts as events instead of display
controls: true, // show or not side menus
device_filter: und, // function to limit devices shown
drop_group: und, // optional array to group multi drop
drop_layout: true, // layout on new drop
hoverAdds: false, // when true only searches widget additions
on_key: und, // function override default key handlers
on_key2: [], // allows for multiple key handlers
on_load: und, // function override file drop loads
on_add_stl: und, // legacy override stl drop loads
on_mouse_up: und, // function intercepts mouse up select
on_mouse_down: und, // function intercepts mouse down
work_alerts: true, // allow disabling work progress alerts
pmode: consts.PMODES.SPEED, // preview modes
// hover: false, // when true fires mouse hover events
get hover() {
return isHover;
},
set hover(b) {
isHover = b;
broker.publish("feature.hover", b);
}
},
busyVal = 0,
busy = {
val() { return busyVal },
inc() { api.event.emit("busy", ++busyVal) },
dec() { api.event.emit("busy", --busyVal) }
},
onkey = (fn) => {
api.feature.on_key2.push(fn);
},
doit = {
undo: noop, // do.js
redo: noop // do.js
},
devel = {
get enabled() {
return settings.ctrl().devel;
},
xray(layers, raw) {
let proc = api.conf.get().process,
size = proc.sliceHeight || proc.slaSlice || 1,
base = (proc.firstSliceHeight || size);
layers = Array.isArray(layers) ? layers : [layers];
proc.xray = layers.map(l => raw ? l : base + l * size - size / 2);
proc.xrayi = layers.slice();
api.function.slice();
}
},
local = {
get: (key) => localGet(key),
getItem: (key) => localGet(key),
getInt: (key) => parseInt(localGet(key)),
getFloat: (key) => parseFloat(localGet(key)),
getBoolean: (key, def = true) => {
let val = localGet(key);
return val === true || val === 'true' || val === def;
},
toggle: (key, val, def) => localSet(key, val ?? !api.local.getBoolean(key, def)),
put: (key, val) => localSet(key, val),
set: (key, val) => localSet(key, val),
setItem: (key, val) => localSet(key, val),
removeItem: (key) => localRemove(key)
},
tweak = {
line_precision(v) { api.work.config({ base: { clipperClean: v } }) },
gcode_decimals(v) { api.work.config({ base: { gcode_decimals: v } }) }
};
function clip(text) {
navigator.clipboard
.writeText(text)
.catch(err => console.error('Clipboard Error:', err));
}
function localGet(key) {
let sloc = api.conf.get().local;
return sloc[key] || api.sdb[key];
}
function localSet(key, val) {
let sloc = api.conf.get().local;
sloc[key] = api.sdb[key] = val;
return val;
}
function localRemove(key) {
let sloc = api.conf.get().local;
return delete sloc[key];
}
export const api = {
ajax,
beta,
alerts,
busy,
catalog,
client: work,
clip,
clone,
color,
conf: settings.conf,
const: { LANG, LOCAL, SETUP, SECURE, STACKS, ...consts },
devel,
device,
devices,
dialog,
doit,
event,
electron: navigator.userAgent.includes('Electron'),
feature,
function: functions,
group,
help,
hide,
image,
js2o,
language: LANG,
lists,
load,
local,
modal,
mode,
new: {
widget: newWidget
},
noop,
o2js,
onkey,
platform,
process,
sdb: dataLocal,
selection,
settings,
show,
space,
SPACE,
stacks: STACKS,
stats,
tool: {
update: updateTool
},
tweak,
uc: UC,
ui: {},
util,
var: {
layer_lo: 0,
layer_hi: 0,
layer_max: 0
},
version,
view,
web,
widgets,
work,
};
// allow widget to straddle client / worker FOR NOW
self.kiri_api = api;

View file

@ -1,12 +1,13 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */ /** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { Layers } from './layers.js'; import { Layers } from '../app/layers.js';
import { newPoint } from '../../geo/point.js'; import { newPoint } from '../../geo/point.js';
import { Polygon, newPolygon } from '../../geo/polygon.js'; import { Polygon, newPolygon } from '../../geo/polygon.js';
import { Slice, newSlice, Top, newTop } from './slice.js'; import { Slice, newSlice, Top, newTop } from './slice.js';
import { Widget, newWidget } from './widget.js'; import { Widget, newWidget } from './widget.js';
const decoders = {}; const decoders = {};
const factories = {};
const freeMem = true; const freeMem = true;
const TYPE = { const TYPE = {
@ -213,6 +214,11 @@ function decodePointArray2D(array, z, fn) {
return points; return points;
} }
function setFactory(type, factory) {
factories[type] = factory;
return codec;
}
// ----- Widget Codec ----- // ----- Widget Codec -----
Widget.prototype.encode = function(state) { Widget.prototype.encode = function(state) {
@ -236,7 +242,8 @@ registerDecoder(TYPE.WIDGET, function(v, state) {
const id = v.id, const id = v.id,
group = v.group || id, group = v.group || id,
track = v.track || undefined, track = v.track || undefined,
widget = newWidget(id, Widget.Groups.forid(group)), factory = factories[TYPE.WIDGET] ?? newWidget,
widget = factory(id, Widget.Groups.forid(group)),
meta= v.meta || widget.meta, meta= v.meta || widget.meta,
anno = v.anno || widget.anno; anno = v.anno || widget.anno;
@ -480,6 +487,7 @@ registerDecoder(TYPE.LAYERS, function(v, state) {
}); });
export const codec = { export const codec = {
TYPE,
encode, encode,
decode, decode,
registerDecoder, registerDecoder,
@ -488,10 +496,12 @@ export const codec = {
decodePointArray, decodePointArray,
encodePointArray2D, encodePointArray2D,
decodePointArray2D, decodePointArray2D,
setFactory,
toCodable toCodable
}; };
export { export {
TYPE,
encode, encode,
decode, decode,
registerDecoder, registerDecoder,
@ -500,6 +510,7 @@ export {
decodePointArray, decodePointArray,
encodePointArray2D, encodePointArray2D,
decodePointArray2D, decodePointArray2D,
setFactory,
toCodable toCodable
}; };

File diff suppressed because it is too large Load diff

View file

@ -1,918 +0,0 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import './frame.js';
import '../../ext/base64.js';
import lang from './lang.js';
import STACKS from './stacks.js';
import { $ } from '../../moto/webui.js';
import { api } from './api.js';
import { broker } from '../../moto/broker.js';
import { consts } from './consts.js';
import { Index } from '../../data/index.js';
import { local as dataLocal } from '../../data/local.js';
import { openFiles } from './files.js';
import { platform } from './platform.js';
import { selection } from './selection.js';
import { settings } from './settings.js';
import { showDevices } from './devices.js';
import { space as SPACE } from '../../moto/space.js';
import { stats } from './stats.js';
import { noop, utils } from './utils.js';
import { version } from '../../moto/license.js';
import { Widget, newWidget } from './widget.js';
import { showTools } from '../mode/cam/tools.js';
let { parseOpt, o2js, js2o, ls2o } = utils,
{ COLOR, MODES, VIEWS } = consts,
LANG = lang.current,
WIN = self.window,
DOC = self.document,
LOC = self.location,
SETUP = parseOpt(LOC.search.substring(1)),
SECURE = isSecure(LOC.protocol),
LOCAL = self.debug && !SETUP.remote,
EVENT = broker,
SDB = dataLocal,
FILES = openFiles(new Index(SETUP.d ? SETUP.d[0] : 'kiri')),
clone = Object.clone,
MODE = MODES.FDM,
viewMode = VIEWS.ARRANGE,
autoSaveTimer = null,
inits = parseInt(SDB.getItem('kiri-init') || stats.get('init') || 0) + 1;
// update version and init count
SDB.setItem('kiri-init', inits);
stats.set('init', inits);
stats.set('kiri', version);
// allow widget to straddle client / worker FOR NOW
self.kiri_catalog = FILES;
export const dialog = {
show: showModal,
hide: hideModal,
update_process_list: updateProcessList
};
export const help = {
show: showHelp,
file: showHelpFile
};
export const event = {
on(t,l) { return EVENT.on(t,l) },
emit(t,m,o) { return EVENT.publish(t,m,o) },
bind(t,m,o) { return EVENT.bind(t,m,o) },
alerts(clr) { api.alerts.update(clr) },
import: loadFile,
settings: triggerSettingsEvent
};
export const group = {
merge: groupMerge,
split: groupSplit,
};
export const hide = {
alert(rec, recs) { api.alerts.hide(...arguments) },
import: noop,
slider: hideSlider
};
export const image = {
dialog: loadImageDialog,
convert: loadImageConvert
};
export const modal = {
show: showModal,
hide: hideModal,
visible: modalShowing
};
export const mode = {
get_id() { return MODE },
get_lower: getModeLower,
get: getMode,
set: setMode,
switch: switchMode,
set_expert: noop,
is_fdm() { return MODE === MODES.FDM },
is_cam() { return MODE === MODES.CAM },
is_sla() { return MODE === MODES.SLA },
is_drag() { return MODE === MODES.DRAG },
is_wedm() { return MODE === MODES.WEDM },
is_wjet() { return MODE === MODES.WJET },
is_laser() { return MODE === MODES.LASER },
is_2d() { return false ||
api.mode.is_drag() ||
api.mode.is_wedm() ||
api.mode.is_wjet() ||
api.mode.is_laser()
}
};
export const process = {
code: currentProcessCode,
get: currentProcessName
};
export const show = {
alert() { return api.alerts.show(...arguments) },
controls: setControlsVisible,
devices: showDevices,
import() { api.ui.import.style.display = '' },
layer: setVisibleLayer,
local: showLocal,
progress: setProgress,
slices: showSlices,
tools: showTools
};
export const space = {
reload,
auto_save,
restore: restoreWorkspace,
clear: clearWorkspace,
save: saveWorkspace,
set_focus: setFocus,
update: SPACE.update,
is_dark() { return settings.ctrl().dark }
};
export const util = {
isSecure,
download: downloadBlob,
ui2rec() { api.conf.update_from(...arguments) },
rec2ui() { api.conf.update_fields(...arguments) },
b64enc(obj) { return base64js.fromByteArray(new TextEncoder().encode(JSON.stringify(obj))) },
b64dec(obj) { return JSON.parse(new TextDecoder().decode(base64js.toByteArray(obj))) }
};
export const view = {
get() { return viewMode },
set() { setViewMode(...arguments) },
set_arrange() { api.view.set(VIEWS.ARRANGE) },
set_slice() { api.view.set(VIEWS.SLICE) },
set_preview() { api.view.set(VIEWS.PREVIEW) },
set_animate() { api.view.set(VIEWS.ANIMATE) },
is_arrange() { return viewMode === VIEWS.ARRANGE },
is_slice() { return viewMode === VIEWS.SLICE },
is_preview() { return viewMode === VIEWS.PREVIEW },
is_animate() { return viewMode === VIEWS.ANIMATE },
update_stack_labels: updateStackLabelState,
update_slider_max: updateSliderMax,
update_slider: updateSlider,
update_speeds: updateSpeeds,
hide_slices: hideSlices,
snapshot: null,
edges: setEdges,
unit_scale: unitScale,
wireframe: setWireframe
};
// add show() to catalog for API
FILES.show = showCatalog;
// patch broker for api backward compatibility
EVENT.on = (topic, listener) => {
EVENT.subscribe(topic, listener);
return EVENT;
};
function updateStackLabelState() {
const settings = api.conf.get();
// match label checkboxes to preference
for (let label of api.stacks.getLabels()) {
let check = `${settings.mode}-${api.view.get()}-${label}`;
api.stacks.setVisible(label, settings.labels[check] !== false);
}
}
function setFocus(sel, point) {
if (point) {
SPACE.platform.setCenter(point.x, point.z, point.y);
SPACE.view.setFocus(new THREE.Vector3(point.x, point.y, point.z));
return;
}
if (sel === undefined) {
sel = api.widgets.all();
} else if (!Array.isArray) {
sel = [ sel ];
} else if (sel.length === 0) {
sel = api.widgets.all();
}
let pos = { x:0, y:0, z:0 };
for (let widget of sel) {
pos.x += widget.track.pos.x;
pos.y += widget.track.pos.y;
pos.z += widget.track.pos.z;
}
if (sel.length) {
pos.x /= sel.length;
pos.y /= sel.length;
pos.z /= sel.length;
}
let cam_index = api.conf.get().process.camStockIndexed || false;
let focus_z = cam_index ? 0 : platform.top_z() / 2;
SPACE.platform.setCenter(pos.x, -pos.y, focus_z);
SPACE.view.setFocus(new THREE.Vector3(pos.x, focus_z, -pos.y));
}
function reload() {
api.event.emit('reload');
do_reload(100);
}
function do_reload(time) {
// allow time for async saves to complete and busy to to to zero
setTimeout(() => {
if (api.busy.val() === 0) {
LOC.reload();
} else {
console.log(`reload deferred on busy=${api.busy.val()}`);
do_reload(250);
}
}, time || 100);
}
function auto_save() {
if (!settings.ctrl().autoSave) {
return;
}
clearTimeout(autoSaveTimer);
autoSaveTimer = setTimeout(() => {
api.space.save(true);
}, 1000);
}
/** ******************************************************************
* Utility Functions
******************************************************************* */
function unitScale() {
return api.mode.is_cam() && settings.ctrl().units === 'in' ? 25.4 : 1;
}
function triggerSettingsEvent() {
api.event.emit('settings', settings.get());
}
function isSecure(proto) {
return proto.toLowerCase().indexOf("https") === 0;
}
function setProgress(value = 0, msg) {
value = (value * 100).round(4);
api.ui.progress.width = value+'%';
if (self.debug) {
// console.log(msg, value.round(2));
api.ui.prostatus.style.display = 'flex';
if (msg) {
api.ui.prostatus.innerHTML = msg;
} else {
api.ui.prostatus.innerHTML = '';
}
}
}
function bound(v,min,max) {
return Math.max(min,Math.min(max,v));
}
function updateSpeeds(maxSpeed, minSpeed) {
const { ui } = api;
ui.speeds.style.display =
maxSpeed &&
settings.mode !== 'SLA' &&
settings.mode !== 'LASER' &&
viewMode === VIEWS.PREVIEW &&
ui.showSpeeds.checked ? 'block' : '';
if (maxSpeed) {
const colors = [];
for (let i = 0; i <= maxSpeed; i += maxSpeed / 20) {
colors.push(Math.round(Math.max(i, 1)));
}
api.client.colors(colors, maxSpeed, speedColors => {
const list = [];
Object.keys(speedColors).map(v => parseInt(v)).sort((a, b) => b - a).forEach(speed => {
const color = speedColors[speed];
const hex = color.toString(16).padStart(6, 0);
const r = (color >> 16) & 0xff;
const g = (color >> 8) & 0xff;
const b = (color >> 0) & 0xff;
const style = `background-color:#${hex}`;
list.push(`<label style="${style}">${speed}</label>`);
});
ui.speedbar.innerHTML = list.join('');
});
api.event.emit('preview.speeds', {
min: minSpeed,
max: maxSpeed
});
}
}
function updateSlider() {
api.event.emit("slider.set", {
start: (api.var.layer_lo / api.var.layer_max),
end: (api.var.layer_hi / api.var.layer_max)
});
api.conf.update_fields_from_range();
}
function setVisibleLayer(h, l) {
h = h >= 0 ? h : api.var.layer_hi;
l = l >= 0 ? l : api.var.layer_lo;
api.var.layer_hi = bound(h, 0, api.var.layer_max);
api.var.layer_lo = bound(l, 0, h);
api.event.emit("slider.label");
updateSlider();
showSlices();
}
function setWireframe(bool, color, opacity) {
api.widgets.each(function(w) { w.setWireframe(bool, color, opacity) });
SPACE.update();
}
function setEdges(bool) {
if (bool && bool.toggle) {
api.local.toggle('model.edges');
} else {
api.local.set('model.edges', bool);
}
bool = api.local.getBoolean('model.edges');
api.widgets.each(w => w.setEdges(bool));
SPACE.update();
}
function updateSliderMax(set) {
let max = STACKS.getRange().tallest - 1;
api.var.layer_max = api.ui.sliderMax.innerText = max;
if (set || max < api.var.layer_hi) {
api.var.layer_hi = api.var.layer_max;
api.event.emit("slider.label");
updateSlider();
}
}
function hideSlices() {
STACKS.clear();
api.widgets.setOpacity(COLOR.model_opacity);
api.widgets.each(function(widget) {
widget.setWireframe(false);
});
}
function setWidgetVisibility(bool) {
api.widgets.each(w => {
if (bool) {
w.show();
} else {
w.hide();
}
});
}
/**
* hide or show slice-layers and their sub-elements
*
* @param {number} [layer]
*/
function showSlices(layer) {
if (viewMode === VIEWS.ARRANGE) {
return;
}
showSlider();
if (typeof(layer) === 'string' || typeof(layer) === 'number') {
layer = parseInt(layer);
} else {
layer = api.var.layer_hi;
}
layer = bound(layer, 0, api.var.layer_max);
if (layer < api.var.layer_lo) api.var.layer_lo = layer;
api.var.layer_hi = layer;
api.event.emit("slider.label");
updateSlider();
STACKS.setRange(api.var.layer_lo, api.var.layer_hi);
SPACE.update();
}
function showSlider() {
api.ui.layers.style.display = 'flex';
api.ui.slider.style.display = 'flex';
}
function hideSlider() {
api.ui.layers.style.display = 'none';
api.ui.slider.style.display = 'none';
api.ui.speeds.style.display = 'none';
}
function loadImageDialog(image, name, force) {
if (!force && image.byteLength > 2500000) {
return api.uc.confirm("Large images may fail to import<br>Consider resizing under 1000 x 1000<br>Proceed with import?").then(ok => {
if (ok) {
loadImageDialog(image, name, true);
}
});
}
const opt = {pre: [
"<div class='f-col a-center'>",
" <h3>Image Conversion</h3>",
" <p class='t-just' style='width:300px;line-height:1.5em'>",
" This will create a 3D model from a 2D PNG image. Photos must",
" be blurred to be usable. Values from 0=off to 50=high are suggested.",
" Higher values incur more processing time.",
" </p>",
" <div class='f-row t-right'><table>",
" <tr><th>blur value</th><td><input id='png-blur' value='0' size='3'></td>",
" <th>&nbsp;invert image</th><td><input id='png-inv' type='checkbox'></td></tr>",
" <tr><th>base size</th><td><input id='png-base' value='0' size='3'></td>",
" <th>&nbsp;invert alpha</th><td><input id='alpha-inv' type='checkbox'></td></tr>",
" <tr><th>border size</th><td><input id='png-border' value='0' size='3'></td>",
" <th></th><td></td></tr>",
" </table></div>",
"</div>"
]};
api.uc.confirm(undefined, {convert:true, cancel:false}, undefined, opt).then((ok) => {
if (ok) {
loadImage(image, {
file: name,
blur: parseInt($('png-blur').value) || 0,
base: parseInt($('png-base').value) || 0,
border: parseInt($('png-border').value) || 0,
inv_image: $('png-inv').checked,
inv_alpha: $('alpha-inv').checked
});
}
});
}
function loadImage(image, opt = {}) {
const info = Object.assign({settings: settings.get(), png:image}, opt);
api.client.image2mesh(info, progress => {
api.show.progress(progress, "converting");
}, vertices => {
api.show.progress(0);
const widget = newWidget().loadVertices(vertices);
widget.meta.file = opt.file;
platform.add(widget);
});
}
/** ******************************************************************
* Selection Functions
******************************************************************* */
function groupMerge() {
Widget.Groups.merge(selection.widgets(true));
}
function groupSplit() {
Widget.Groups.split(selection.widgets(false));
}
/** ******************************************************************
* Settings Functions
******************************************************************* */
// convert any image type to png
function loadImageConvert(res, name) {
let url = URL.createObjectURL(new Blob([res]));
$('mod-any').innerHTML = `<img id="xsrc" src="${url}"><canvas id="xdst"></canvas>`;
let img = $('xsrc');
let can = $('xdst');
img.onload = () => {
can.width = img.width;
can.height = img.height;
let ctx = can.getContext('2d');
ctx.drawImage(img, 0, 0);
fetch(can.toDataURL()).then(r => r.arrayBuffer()).then(data => {
loadImageDialog(data, name);
});
};
}
function loadFile(ev) {
// use modern Filesystem api when available
if (false && window.showOpenFilePicker) {
window.showOpenFilePicker().then(files => {
return Promise.all(files.map(fh => fh.getFile()))
}).then(files => {
if (files.length) {
api.platform.load_files(files);
}
}).catch(e => { /* ignore cancel */ });
return;
}
api.ui.load.click();
}
function saveWorkspace(quiet) {
api.conf.save();
const newWidgets = [];
const oldWidgets = js2o(SDB.getItem('ws-widgets'), []);
api.widgets.each(function(widget) {
if (widget.synth) return;
newWidgets.push(widget.id);
oldWidgets.remove(widget.id);
widget.saveState();
let ann = api.widgets.annotate(widget.id);
ann.file = widget.meta.file;
ann.url = widget.meta.url;
});
SDB.setItem('ws-widgets', o2js(newWidgets));
oldWidgets.forEach(wid => {
Widget.deleteFromState(wid);
});
// eliminate dangling saved widgets
FILES.deleteFilter(key => newWidgets.indexOf(key.substring(8)) < 0, "ws-save-", "ws-savf");
if (!quiet) {
api.show.alert("workspace saved", 1);
}
}
function restoreWorkspace(ondone, skip_widget_load) {
let newset = api.conf.restore(false),
camera = newset.controller.view,
toload = ls2o('ws-widgets',[]),
loaded = 0,
position = true;
api.conf.update_fields();
platform.update_size();
SPACE.view.reset();
if (camera) {
SPACE.view.load(camera);
} else {
SPACE.view.home();
}
if (skip_widget_load) {
if (ondone) {
ondone();
}
return;
}
// remove any widgets from platform
api.widgets.each(function(widget) {
platform.delete(widget);
});
// load any widget by name that was saved to the workspace
toload.forEach(function(widgetid) {
Widget.loadFromState(widgetid, function(widget) {
if (widget) {
platform.add(widget, 0, position, true);
let ann = api.widgets.annotate(widgetid);
widget.meta.file = ann.file;
widget.meta.url = ann.url;
}
if (++loaded === toload.length) {
platform.deselect();
if (ondone) {
ondone();
setTimeout(() => {
platform.update_bounds();
SPACE.update();
}, 1);
}
}
}, position);
});
return toload.length > 0;
}
function clearWorkspace() {
// free up worker cache/mem
api.client.clear();
platform.select_all();
platform.delete(selection.meshes());
}
function modalShowing() {
return api.ui.modal.style.display === 'flex';
}
function showModal(which) {
let mod = api.ui.modal,
style = mod.style,
visible = modalShowing(),
info = { pct: 0 };
// hide all modals befroe showing another
Object.keys(api.ui.modals).forEach(name => {
api.ui.modals[name].style.display = name === which ? 'flex' : '';
});
function ondone() {
api.event.emit('modal.show', which);
}
if (visible) {
return ondone();
}
style.height = '0';
style.display = 'flex';
new TWEEN.Tween(info).
easing(TWEEN.Easing.Quadratic.InOut).
to({ pct: 100 }, 100).
onUpdate(() => { style.height = `${info.pct}%` }).
onComplete(ondone).
start();
}
function hideModal() {
if (!modalShowing()) {
return;
}
let mod = api.ui.modal, style = mod.style, info={pct:100};
new TWEEN.Tween(info).
easing(TWEEN.Easing.Quadratic.InOut).
to({pct:0}, 100).
onUpdate(() => { style.height = `${info.pct}%` }).
onComplete(() => {
style.display = '';
api.event.emit('modal.hide');
}).
start();
}
function showCatalog() {
showModal("files");
}
function editSettings(e) {
let current = settings.get(),
mode = getMode(),
name = e.target.getAttribute("name"),
load = current.sproc[mode][name],
loadstr = JSON.stringify(load,null,4).split('\n');
api.uc.prompt(`settings for "${name}"`, loadstr).then(edit => {
if (edit) {
try {
current.sproc[mode][name] = JSON.parse(edit);
if (name === settings.proc().processName) {
api.conf.load(null, name);
}
api.conf.save();
api.settings.sync.put();
} catch (e) {
console.log({ malformed_settings: e });
api.uc.alert('malformed settings object');
}
}
});
}
function exportSettings(e) {
let current = settings.get(),
mode = getMode(),
name = e.target.getAttribute("name"),
data = api.util.b64enc({
process: current.sproc[mode][name],
version,
moto: moto.id,
time: Date.now(),
mode,
name
});
api.uc.prompt("Export Process Filename", name).then(name => {
if (name) {
api.util.download(data, `${name}.km`);
}
});
}
function deleteSettings(e) {
let current = settings.get();
let name = e.target.getAttribute("del");
delete current.sproc[getMode()][name];
api.settings.sync.put();
updateProcessList();
api.conf.save();
triggerSettingsEvent();
}
function updateProcessList() {
let current = settings.get();
let list = [], s = current, sp = s.sproc[getMode()] || {}, table = api.ui.settingsList;
table.innerHTML = '';
for (let k in sp) {
if (sp.hasOwnProperty(k)) list.push(k);
}
list.filter(n => n !=='default').sort().forEach(function(sk) {
let row = DOC.createElement('div'),
load = DOC.createElement('button'),
edit = DOC.createElement('button'),
xprt = DOC.createElement('button'),
del = DOC.createElement('button'),
name = sk;
load.setAttribute('load', sk);
load.onclick = (ev) => {
api.conf.load(undefined, sk);
updateProcessList();
hideModal();
}
load.appendChild(DOC.createTextNode(sk));
if (sk == settings.proc().processName) {
load.setAttribute('class', 'selected')
}
api.ui.settingsName.value = settings.proc().processName;
del.setAttribute('del', sk);
del.setAttribute('title', "remove '"+sk+"'");
del.innerHTML = '<i class="far fa-trash-alt"></i>';
del.onclick = deleteSettings;
edit.setAttribute('name', sk);
edit.setAttribute('title', 'edit');
edit.innerHTML = '<i class="far fa-edit"></i>';
edit.onclick = editSettings;
xprt.setAttribute('name', sk);
xprt.setAttribute('title', 'export');
xprt.innerHTML = '<i class="fas fa-download"></i>';
xprt.onclick = exportSettings;
row.setAttribute("class", "flow-row");
row.appendChild(edit);
row.appendChild(load);
row.appendChild(xprt);
row.appendChild(del);
table.appendChild(row);
});
}
function showHelp() {
showHelpFile(`local`,() => {});
}
function showHelpFile(local,then) {
if (!local) {
WIN.open("//docs.grid.space/", "_help");
return;
}
const LANG = api.language.current;
$('kiri-version').innerHTML = `${LANG.version} ${version}`;
showModal('help');
api.event.emit('help.show', local);
}
function showLocal() {
// deprecated
}
function setViewMode(mode) {
const isCAM = settings.mode() === 'CAM';
viewMode = mode;
platform.deselect();
selection.update_info();
// clear any bounds forced by, for example, WireEDM previews
api.platform.set_bounds();
// disable clear in non-arrange modes
['view-arrange','act-slice','act-preview','act-animate'].forEach(el => {
$(el).classList.remove('selected')
});
$('render-tools').classList.add('hide');
switch (mode) {
case VIEWS.ARRANGE:
$('view-arrange').classList.add('selected');
$('render-tools').classList.remove('hide');
api.function.clear_progress();
api.client.clear();
STACKS.clear();
hideSlider();
updateSpeeds();
setVisibleLayer();
setWidgetVisibility(true);
api.widgets.setOpacity(1);
api.view.edges(api.local.getBoolean('model.edges'));
break;
case VIEWS.SLICE:
$('act-slice').classList.add('selected');
updateSpeeds();
updateSliderMax();
setWidgetVisibility(true);
!isCAM && api.view.edges(false);
break;
case VIEWS.PREVIEW:
$('act-preview').classList.add('selected');
setWidgetVisibility(true);
!isCAM && api.view.edges(false);
break;
case VIEWS.ANIMATE:
$('act-animate').classList.add('selected');
!isCAM && api.view.edges(false);
break;
default:
console.log("invalid view mode: "+mode);
return;
}
api.event.emit('view.set', mode);
DOC.activeElement.blur();
}
function getMode() {
return settings.mode();
}
function getModeLower() {
return getMode().toLowerCase();
}
function switchMode(mode) {
setMode(mode, null, platform.update_size);
}
function setMode(mode, lock, then) {
if (!MODES[mode]) {
console.log("invalid mode: "+mode);
mode = 'FDM';
}
const current = settings.get();
// change mode constants
current.mode = mode;
MODE = MODES[mode];
// gcode edit area for any non-SLA mode
api.uc.setVisible($('gcode-edit'), mode !== 'SLA');
// highlight selected mode menu item
["FDM","CAM","SLA","LASER","DRAG","WJET","WEDM"].forEach(sm => {
const cl = $(`mode-${sm.toLowerCase()}`).classList;
if (sm === mode) {
cl.add('selected');
} else {
cl.remove('selected');
}
});
// restore cached device profile for this mode
if (current.cdev[mode]) {
current.device = clone(current.cdev[mode]);
api.event.emit('device.select', api.device.get());
}
// hide/show
api.uc.setVisible($('set-tools'), mode === 'CAM');
// updates right-hand menu by enabling/disabling fields
setViewMode(VIEWS.ARRANGE);
api.uc.setMode(MODE);
// sanitize and persist settings
api.conf.load();
api.conf.save();
// other housekeeping
triggerSettingsEvent();
platform.update_selected();
selection.update_bounds(api.widgets.all());
api.conf.update_fields();
// because device dialog, if showing, needs to be updated
if (modalShowing()) {
api.show.devices();
}
api.space.restore(null, true);
api.event.emit("mode.set", mode);
if (then) {
then();
}
}
function currentProcessName() {
return settings.get().cproc[getMode()];
}
function currentProcessCode() {
return settings.get().sproc[getMode()][currentProcessName()];
}
function setControlsVisible(show) {
// TODO fix
// $('mid-left').style.display = show ? 'flex' : 'none';
// $('mid-right').style.display = show ? 'flex' : 'none';
}
function downloadBlob(data, filename) {
let url = WIN.URL.createObjectURL(new Blob([data], {type: "octet/stream"}));
$('mod-any').innerHTML = `<a id="_dexport_" href="${url}" download="${filename}">x</a>`;
$('_dexport_').click();
}
// prevent safari from exiting full screen mode
DOC.onkeydown = function (evt) { if (evt.keyCode == 27) evt.preventDefault() }
export { FILES as catalog, LOCAL, SETUP, SECURE };

View file

@ -1,14 +1,14 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */ /** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { arcToPath } from '../../geo/paths.js'; import { arcToPath } from '../../geo/paths.js';
import { consts } from './consts.js'; import { createVM } from '../../moto/quickjs.js';
import { newPoint } from '../../geo/point.js'; import { newPoint } from '../../geo/point.js';
import { newPolygon } from '../../geo/polygon.js';
import { util } from '../../geo/base.js'; import { util } from '../../geo/base.js';
const { numOrDefault } = util; const { numOrDefault } = util;
const { beltfact } = consts;
const XAXIS = new THREE.Vector3(1,0,0); const BELTFACT = Math.cos(Math.PI / 4);
const DEG2RAD = Math.PI / 180; const DEG2RAD = Math.PI / 180;
class Print { class Print {
@ -24,6 +24,7 @@ class Print {
this.tools = {}; this.tools = {};
// set to 1 to enable flow rate analysis (console) // set to 1 to enable flow rate analysis (console)
this.debugE = settings ? (settings.controller.devel ? 1 : 0) : 0; this.debugE = settings ? (settings.controller.devel ? 1 : 0) : 0;
this._ready = this.createSafeEval();
} }
setType(type) { setType(type) {
@ -40,7 +41,7 @@ class Print {
* addOutput - add a new point to the output gcode array * addOutput - add a new point to the output gcode array
* @param {any[]} array - the output gcode array * @param {any[]} array - the output gcode array
* @param {Point} point - the new point * @param {Point} point - the new point
* @param {number} emit - the extrusion value * @param {number} emit - the extrusion value (G? value for cnc)
* @param {number} speed - the feed rate * @param {number} speed - the feed rate
* @param {string} tool - the tool id * @param {string} tool - the tool id
* @param {"lerp"|string} opts.type - the output type * @param {"lerp"|string} opts.type - the output type
@ -50,7 +51,7 @@ class Print {
* @return {Output} - the new output object * @return {Output} - the new output object
*/ */
addOutput(array, point, emit, speed, tool, opts) { addOutput(array, point, emit, speed, tool, opts) {
const { type, retract, center, arcPoints} = opts ?? {}; const { type, retract, center, arcPoints, clockwise, distance } = opts ?? {};
let { lastPoint, lastEmit, lastOut } = this; let { lastPoint, lastEmit, lastOut } = this;
let arc = emit == 2 || emit == 3; let arc = emit == 2 || emit == 3;
// drop duplicates (usually intruced by FDM bisections) // drop duplicates (usually intruced by FDM bisections)
@ -67,7 +68,9 @@ class Print {
this.lastOut = lastOut = new Output(point, emit, speed, tool, { this.lastOut = lastOut = new Output(point, emit, speed, tool, {
type: type ?? this.nextType, type: type ?? this.nextType,
center, center,
clockwise,
arcPoints, arcPoints,
distance
}); });
if (tool !== undefined) { if (tool !== undefined) {
this.tools[tool] = true; this.tools[tool] = true;
@ -128,10 +131,9 @@ class Print {
perimeter = poly.perimeter(), perimeter = poly.perimeter(),
close = !options.open, close = !options.open,
scarf = !poly.open ? (options.scarf ?? 0) : false, scarf = !poly.open ? (options.scarf ?? 0) : false,
tool = options.tool,
zmax = options.zmax,
last = startPoint, last = startPoint,
first = true; first = true,
{ arcOpts, tool, zmax, onfirst, onfirstout } = options;
// if short, use calculated print speed based on sliding scale // if short, use calculated print speed based on sliding scale
if (perimeter < process.outputShortPoly) { if (perimeter < process.outputShortPoly) {
@ -167,7 +169,7 @@ class Print {
if (scarf) { if (scarf) {
let epz = Math.max(...poly.points.map(p => p.z)); let epz = Math.max(...poly.points.map(p => p.z));
let spz = startPoint.z; let spz = startPoint.z;
poly = poly.segment(options.nozzleSize ?? 0.4, false, false, scarf * 2); poly = poly.segment(nozzleSize ?? 0.4, false, false, scarf * 2);
pp = poly.points; pp = poly.points;
let lp, sp = []; let lp, sp = [];
for (let p of pp) { for (let p of pp) {
@ -191,6 +193,10 @@ class Print {
} }
pp.push(...esp); pp.push(...esp);
scarf = true; scarf = true;
} else if (arcOpts) {
// annotate arc points
pp = newPolygon(pp).detectArcs(arcOpts).points;
// console.log(arcOpts, pp.map(p => p.arc?.skip).filter(v => v));
} }
// scarf manages its own close point // scarf manages its own close point
@ -199,19 +205,44 @@ class Print {
} }
let lpo; let lpo;
let arcInfo;
let arcDist = 0;
for (let point of pp) { for (let point of pp) {
let { arc } = point;
if (arc) {
arcInfo = arc;
if (first && onfirst) onfirst(point, output);
let out = scope.addOutput(output, point, shellMult, moveSpeed, tool);
if (first && onfirstout) onfirstout(out);
// arc center is relative to first point
arcInfo.center.move({ x: -point.x, y: -point.y, z: 0 });
last = point;
if (first) first = false;
continue;
} else if (arcInfo) {
// accumulate and pass to last point to calc FDM E value
arcDist += last.distTo2D(point);
if (--arcInfo.skip === 0) {
arcInfo.distance = arcDist;
scope.addOutput(output, point, shellMult, moveSpeed, tool, arcInfo);
arcInfo = undefined;
arcDist = 0;
} else {
// non-emit arc points get -1 for rendering
scope.addOutput(output, point, -1, moveSpeed, tool);
}
last = point;
continue;
}
if (point.skip && lpo?.skip) { if (point.skip && lpo?.skip) {
// basic thin wall skips are converted to moves
scope.addOutput(output, point, 0, moveSpeed, tool); scope.addOutput(output, point, 0, moveSpeed, tool);
} else if (first) { } else if (first) {
// if (point.skip) console.log({ skip: point }); // if (point.skip) console.log({ skip: point });
if (options.onfirst) { onfirst && onfirst(point, output);
options.onfirst(point, output);
}
// move to first output point on poly // move to first output point on poly
let out = scope.addOutput(output, point, 0, moveSpeed, tool); let out = scope.addOutput(output, point, 0, moveSpeed, tool);
if (options.onfirstout) { onfirstout && onfirstout(out);
options.onfirstout(out);
}
first = false; first = false;
} else { } else {
let seglen = last.distTo2D(point); let seglen = last.distTo2D(point);
@ -237,7 +268,26 @@ class Print {
return output.last().point; return output.last().point;
} }
async ready() {
await this._ready;
}
async createSafeEval() {
this.safeEval = await createVM();
this.safeEval.eval("function range(a,b) { return (a + (layer / layers) * (b-a)) }");
}
disposeSafeEval() {
if (this.safeEval) {
this.safeEval.dispose();
}
}
constReplace(str, consts, start, pad, short) { constReplace(str, consts, start, pad, short) {
let safeEval = this.safeEval;
if (safeEval) {
safeEval.setContext(consts);
}
function tryeval(str) { function tryeval(str) {
try { try {
return eval(`{ ${str} }`) return eval(`{ ${str} }`)
@ -269,7 +319,7 @@ class Print {
} }
eva.push(`function range(a,b) { return (a + (layer / layers) * (b-a)).round(4) }`); eva.push(`function range(a,b) { return (a + (layer / layers) * (b-a)).round(4) }`);
eva.push(`try {( ${tok} )} catch (e) {console.log(e);0}`); eva.push(`try {( ${tok} )} catch (e) {console.log(e);0}`);
let evl = tryeval(eva.join('')); let evl = safeEval ? safeEval.eval(tok) : tryeval(eva.join(''));
nutok = evl; nutok = evl;
if (pad === 666) { if (pad === 666) {
return evl; return evl;
@ -538,8 +588,8 @@ class Print {
let { point, prevPoint } = processLine(line, axes); let { point, prevPoint } = processLine(line, axes);
if (morph && belt) { if (morph && belt) {
point.y -= point.z * beltfact; point.y -= point.z * BELTFACT;
point.z *= beltfact; point.z *= BELTFACT;
} }
const retract = (fdm && pos.E < 0) || undefined; const retract = (fdm && pos.E < 0) || undefined;
@ -725,15 +775,16 @@ class Output {
*/ */
constructor(point, emit, speed, tool, options) { constructor(point, emit, speed, tool, options) {
const { type, center, arcPoints } = (options ?? {}); const { type, center, arcPoints, clockwise, distance } = (options ?? {});
//speed, tool, type, center, arcPoints
this.point = point; this.point = point;
this.emit = Number(emit); //convert bools into 0/1 this.emit = Number(emit); //convert bools into 0/1
this.speed = speed; this.speed = speed;
this.tool = tool; this.tool = tool;
this.type = type; this.type = type;
this.center = center; this.center = center;
this.arcPoints = arcPoints; this.clockwise = clockwise;
this.distance = distance;
// this.where = new Error().stack.split("\n"); // this.where = new Error().stack.split("\n");
} }

View file

@ -1,6 +1,6 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */ /** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { Layers } from './layers.js'; import { Layers } from '../app/layers.js';
import { newPoint } from '../../geo/point.js'; import { newPoint } from '../../geo/point.js';
import { newPolygon } from '../../geo/polygon.js'; import { newPolygon } from '../../geo/polygon.js';
import { newSlopeFromAngle } from '../../geo/slope.js'; import { newSlopeFromAngle } from '../../geo/slope.js';

View file

@ -1,354 +0,0 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { api } from './api.js';
import { space } from '../../moto/space.js';
import { THREE } from '../../ext/three.js';
import { tool as MeshTool } from '../../mesh/tool.js';
const selectedMeshes = [];
function for_groups(fn) {
let groups = widgets(true).map(w => w.group).uniq();
for (let group of groups) {
fn(group[0]);
}
}
function for_widgets(fn, noauto) {
let m = selectedMeshes;
let w = api.widgets.all();
if (m.length === 0 && w.length) {
m = noauto ? [] : w.map(w => w.mesh);
}
m.slice().forEach(mesh => { fn(mesh.widget) });
}
function for_status(fn) {
api.widgets.all().forEach(w => {
fn(w, selectedMeshes.contains(w.mesh));
});
}
function for_meshes(fn) {
selectedMeshes.slice().forEach(mesh => { fn(mesh) });
}
function move(x, y, z, abs) {
if (!api.view.is_arrange()) {
return;
}
for_groups(w => w.move(x, y, z, abs));
update_bounds();
api.platform.update_bounds();
api.event.emit('selection.move', {x, y, z, abs});
space.update();
api.space.auto_save();
}
function update_info() {
const ui = api.ui;
let bounds = new THREE.Box3(), track;
for_meshes(mesh => {
let box = mesh.getBoundingBox().clone();
track = mesh.widget.track;
let {pos} = track
box.min.add(pos);
box.max.add(pos);
bounds = bounds.union(box);
});
if (bounds.min.x === Infinity) {
if (selectedMeshes.length === 0) {
ui.sizeX.value = 0;
ui.sizeY.value = 0;
ui.sizeZ.value = 0;
ui.scaleX.value = 1;
ui.scaleY.value = 1;
ui.scaleZ.value = 1;
}
return;
}
let dx = bounds.max.x - bounds.min.x,
dy = bounds.max.y - bounds.min.y,
dz = bounds.max.z - bounds.min.z,
scale = api.view.unit_scale();
ui.sizeX.value = ui.sizeX.was = (dx / scale).round(2)
ui.sizeY.value = ui.sizeY.was = (dy / scale).round(2)
ui.sizeZ.value = ui.sizeZ.was = (dz / scale).round(2)
ui.scaleX.value = ui.scaleX.was = track.scale.x.round(2);
ui.scaleY.value = ui.scaleY.was = track.scale.y.round(2);
ui.scaleZ.value = ui.scaleZ.was = track.scale.z.round(2);
update_bounds();
}
function update_bounds(widgets) {
const settings = api.conf.get();
// update bounds on selection for drag limiting
const isBelt = settings.device.bedBelt;
if (isBelt) {
if (api.platform.fit()) {
api.platform.update_origin();
space.update();
}
}
const dvy = settings.device.bedDepth;
const dvx = settings.device.bedWidth;
const bounds_sel = new THREE.Box3();
if (!widgets) {
widgets = selectedMeshes.map(m => m.widget);
}
for (let widget of widgets) {
const wp = widget.track.pos;
const bx = widget.track.box;
const miny = wp.y - bx.h / 2 + dvy / 2;
const maxy = wp.y + bx.h / 2 + dvy / 2;
const minx = wp.x - bx.w / 2 + dvx / 2;
const maxx = wp.x + bx.w / 2 + dvx / 2;
// keep widget in bounds when rotated or scaled
const ylo = miny < 0;
const yhi = !isBelt && maxy > dvy
if (ylo && !yhi) {
widget.move(0, -miny, 0);
} else if (yhi && !ylo) {
widget.move(0, dvy - maxy, 0);
}
const xlo = minx < 0;
const xhi = maxx > dvx;
if (xlo && !xhi) {
widget.move(-minx, 0, 0);
} else if (xhi && !xlo) {
widget.move(dvx - maxx, 0, 0);
}
const wb = widget.mesh.getBoundingBox().clone();
wb.min.x += wp.x;
wb.max.x += wp.x;
wb.min.y += wp.y;
wb.max.y += wp.y;
bounds_sel.union(wb);
}
settings.bounds_sel = bounds_sel;
}
function duplicate() {
if (!api.view.is_arrange()) {
return;
}
for_widgets(widget => {
const mesh = widget.mesh;
const bb = mesh.getBoundingBox();
const ow = widget;
const nw = api.widgets.new().loadGeometry(mesh.geometry.clone());
nw.meta.file = ow.meta.file;
nw.meta.vertices = ow.meta.vertices;
nw.anno = ow.annotations();
nw.move(bb.max.x - bb.min.x + 1, 0, 0);
api.platform.add(nw, true);
api.event.emit("widget.duplicate", nw, ow);
});
}
function mirror() {
if (!api.view.is_arrange()) {
return;
}
for_widgets(widget => {
widget.mirror();
});
space.update();
api.space.auto_save();
}
function scale() {
if (!api.view.is_arrange()) {
return;
}
let args = [...arguments];
for_groups(w => w.scale(...args));
selection.update_bounds();
api.platform.update_bounds();
api.event.emit('selection.scale', args);
// skip update if last argument is strictly 'false'
if (args.last() === false) {
return;
}
api.space.auto_save();
selection.update_info();
space.update();
}
function rotate(x, y, z) {
if (!api.view.is_arrange()) {
return;
}
for_groups(w => w.rotate(x, y, z));
selection.update_bounds();
api.platform.update_bounds();
api.event.emit('selection.rotate', {x, y, z});
api.space.auto_save();
selection.update_info();
space.update();
}
function merge({ deleteMerged = true }) {
let sel = widgets();
if (sel.length === 0) {
sel = api.widgets.all();
}
let obj = [];
sel.forEach(widget => {
let {x, y, z} = widget.track.pos || { x:0, y:0, z:0 };
let pos = widget.mesh.geometry.attributes.position;
let pvals = pos.array;
for (let i=0, il=pos.count; i<il; i += 3) {
let pi = i * pos.itemSize;
obj.push(pvals[pi++] + x, pvals[pi++] + y, pvals[pi++] + z);
obj.push(pvals[pi++] + x, pvals[pi++] + y, pvals[pi++] + z);
obj.push(pvals[pi++] + x, pvals[pi++] + y, pvals[pi++] + z);
}
});
let w = api.platform.load_verts([], obj, "merged");
if (deleteMerged) api.platform.delete(sel);
api.platform.select(w);
}
/**
* Isolates the bodies within the selected widgets. If no widgets are selected,
* it processes all available widgets. For each widget, it checks if a tool is
* associated; if not, it creates a new MeshTool and indexes the vertices.
* The function identifies isolated bodies within each widget. If a widget has
* a single body, it remains selected. Otherwise, it creates new widgets for
* each isolated body and selects them while deleting the original widget.
*/
function isolateBodies(){
let sel = widgets();
console.log('isolate', sel);
if (sel.length === 0) {
sel = api.widgets.all();
}
for(let widget of sel){
if (!widget.tool) {
let tool = widget.tool = new MeshTool();
tool.index(widget.getGeoVertices({ unroll: true, translate:true }));
}
let bodies = widget.tool.isolateBodies()
if(bodies.length ==1){
api.platform.select(widget);
}else{
// console.log(bodies,Array.from(bodies.entries()))
for(let [i,verts] of bodies.entries()){
let w = api.platform.load_verts([], verts, `isolate ${i} of ${ widget.anno.file}`);
api.platform.select(w)
}
api.platform.delete(widget);
}
}
}
function exportWidgets(format = "stl") {
let sel = widgets();
if (sel.length === 0) {
sel = api.widgets.all();
}
let facets = 0;
let outs = [];
sel.forEach(widget => {
let mesh = widget.mesh;
let geo = mesh.geometry;
outs.push({geo, widget});
facets += geo.attributes.position.count;
});
if (format === "obj") {
let obj = [];
let vpad = 0;
for (let out of outs) {
let meta = out.widget.meta;
let name = meta.file || 'unnamed';
obj.push(`g ${name}`);
let { position } = out.geo.attributes;
let pvals = position.array;
for (let i=0, il=position.count; i<il; i += 3) {
let pi = i * position.itemSize;
obj.push(`v ${pvals[pi++]} ${pvals[pi++]} ${pvals[pi++]}`);
obj.push(`v ${pvals[pi++]} ${pvals[pi++]} ${pvals[pi++]}`);
obj.push(`v ${pvals[pi++]} ${pvals[pi++]} ${pvals[pi++]}`);
obj.push(`f ${i+1+vpad} ${i+2+vpad} ${i+3+vpad}`);
}
vpad += position.count;
}
return obj.join('\n');
}
let stl = new Uint8Array(80 + 4 + facets/3 * 50);
let dat = new DataView(stl.buffer);
let pos = 84;
dat.setInt32(80, facets/3, true);
for (let out of outs) {
let { position } = out.geo.attributes;
let pvals = position.array;
for (let i=0, il=position.count; i<il; i += 3) {
let pi = i * position.itemSize;
let p0 = new THREE.Vector3(pvals[pi++], pvals[pi++], pvals[pi++]);
let p1 = new THREE.Vector3(pvals[pi++], pvals[pi++], pvals[pi++]);
let p2 = new THREE.Vector3(pvals[pi++], pvals[pi++], pvals[pi++]);
let norm = THREE.computeFaceNormal(p0, p1, p2);
let xo = 0, yo = 0, zo = 0;
if (outs.length > 1) {
let {x, y, z} = out.widget.track.pos;
xo = x;
yo = y;
zo = z;
}
dat.setFloat32(pos + 0, norm.x, true);
dat.setFloat32(pos + 4, norm.y, true);
dat.setFloat32(pos + 8, norm.z, true);
dat.setFloat32(pos + 12, p0.x + xo, true);
dat.setFloat32(pos + 16, p0.y + yo, true);
dat.setFloat32(pos + 20, p0.z + zo, true);
dat.setFloat32(pos + 24, p1.x + xo, true);
dat.setFloat32(pos + 28, p1.y + yo, true);
dat.setFloat32(pos + 32, p1.z + zo, true);
dat.setFloat32(pos + 36, p2.x + xo, true);
dat.setFloat32(pos + 40, p2.y + yo, true);
dat.setFloat32(pos + 44, p2.z + zo, true);
pos += 50;
}
}
return stl;
}
function widgets(orall) {
let sel = selectedMeshes.slice().map(m => m.widget);
return sel.length ? sel : orall ? api.widgets.all() : []
}
function setDisabled(bool) {
for_widgets(w => w.meta.disabled = bool);
api.platform.update_selected();
}
// extend API (api.selection)
export const selection = {
move,
merge,
isolateBodies,
scale,
rotate,
mirror,
duplicate,
for_groups,
for_meshes,
for_status,
for_widgets,
update_bounds,
update_info,
widgets,
export: exportWidgets,
add(w) { selectedMeshes.addOnce(w.mesh) },
delete() { api.platform.delete(widgets()) },
remove(w) { return selectedMeshes.remove(w.mesh) },
count() { return selectedMeshes.length },
contains(w) { return selectedMeshes.indexOf(w.mesh) >= 0 },
enable() { setDisabled(false) },
disable() { setDisabled(true) },
opacity() { api.widgets.opacity(...arguments) },
meshes() { return selectedMeshes.slice() },
};

View file

@ -1,6 +1,6 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */ /** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { Layers } from './layers.js'; import { Layers } from '../app/layers.js';
import { newPoint } from '../../geo/point.js'; import { newPoint } from '../../geo/point.js';
import { polygons as POLY } from '../../geo/polygons.js'; import { polygons as POLY } from '../../geo/polygons.js';
@ -123,9 +123,9 @@ class Slice {
return slice ? POLY.fingerprintCompare(this.fingerprint(), slice.fingerprint()) : false; return slice ? POLY.fingerprintCompare(this.fingerprint(), slice.fingerprint()) : false;
}; };
addTops(polys) { addTops(polys, opt) {
polys.forEach(p => { polys.forEach(p => {
this.addTop(p); this.addTop(p, opt);
}); });
return this; return this;
} }
@ -134,16 +134,23 @@ class Slice {
* @param {Object | Polygon} data * @param {Object | Polygon} data
* @returns Slice.Top * @returns Slice.Top
*/ */
addTop(data) { addTop(data, opt = {}) {
let minArea = opt.minArea ?? 0;
if (data.length) { if (data.length) {
// standard legacy polygon // standard legacy polygon
let top = new Top(data); let top = new Top(data);
if (top.area() < minArea) {
return null;
}
this.tops.push(top); this.tops.push(top);
top.simple = data; top.simple = data;
return top; return top;
} else { } else {
// create top object from object bundle passed back by slicePost() // create top object from object bundle passed back by slicePost()
let top = new Top(data.poly); let top = new Top(data.poly);
if (top.area() < minArea) {
return null;
}
top.thin_fill = data.thin_fill?.map(p => newPoint(p.x,p.y,p.z)); top.thin_fill = data.thin_fill?.map(p => newPoint(p.x,p.y,p.z));
top.thin_wall = data.thin_wall; top.thin_wall = data.thin_wall;
top.thin_sort = data.thin_sort; top.thin_sort = data.thin_sort;
@ -152,7 +159,7 @@ class Slice {
top.fill_off = data.fill_off; top.fill_off = data.fill_off;
top.last = data.last; top.last = data.last;
top.gaps = data.gaps; top.gaps = data.gaps;
top.shells = data.shells; top.shells = data.shells.filter(p => p.area() > minArea);
top.simple = data.simple; top.simple = data.simple;
this.tops.push(top); this.tops.push(top);
return top; return top;
@ -208,6 +215,10 @@ class Top {
this.poly = polygon; // outline poly this.poly = polygon; // outline poly
} }
area() {
return this.poly.area();
}
clone(deep) { clone(deep) {
let top = new Top(this.poly.clone(deep)); let top = new Top(this.poly.clone(deep));
return top; return top;

View file

@ -1,7 +1,6 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */ /** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { base } from '../../geo/base.js'; import { decode } from '../core/codec.js';
import { avgc } from './utils.js';
import { util as mesh_util } from '../../mesh/util.js'; import { util as mesh_util } from '../../mesh/util.js';
import { tool as mesh_tool } from '../../mesh/tool.js'; import { tool as mesh_tool } from '../../mesh/tool.js';
import { verticesToPoints } from '../../geo/points.js'; import { verticesToPoints } from '../../geo/points.js';
@ -10,27 +9,21 @@ import { newPolygon } from '../../geo/polygon.js';
import { polygons as POLY } from '../../geo/polygons.js'; import { polygons as POLY } from '../../geo/polygons.js';
import { checkOverUnderOn, intersectPoints } from '../../geo/slicer.js'; import { checkOverUnderOn, intersectPoints } from '../../geo/slicer.js';
const { inRange, time } = base.util;
const solid_opacity = 1.0;
const groups = [];
const sharedArrayClass = self.SharedArrayBuffer || undefined; const sharedArrayClass = self.SharedArrayBuffer || undefined;
const hasSharedArrays = sharedArrayClass ? true : false; const hasSharedArrays = sharedArrayClass ? true : false;
const solid_opacity = 1.0;
const groups = [];
const debug_shadow = false;
let nextId = 0; let nextId = 0;
function newWidget(id,group) { return new Widget(id,group) }
function catalog() { return self.kiri_catalog };
function index() { return self.kiri_catalog.index }
class Widget { class Widget {
constructor(id, group) { constructor(id, group) {
this.api = self.kiri_api; this.api = self.kiri_api;
this.id = id || Date.now().toString(36)+(nextId++); this.id = id || Date.now().toString(36)+(nextId++);
this.grouped = group ? true : false; this.grouped = group ? true : false;
// persisted
this.group = group || []; this.group = group || [];
this.group.push(this); this.group.push(this);
if (!this.group.id) { if (!this.group.id) {
@ -41,10 +34,8 @@ class Widget {
} }
// rotation stack (for undo) // rotation stack (for undo)
this.roto = []; this.roto = [];
// added meshes (supports, tabs, etc) // overlay meshes (supports, tabs, etc)
this.adds = []; this.adds = [];
// persisted client annotations (cam tabs, fdm supports)
this.anno = {};
// THREE Mesh and points // THREE Mesh and points
this.mesh = null; this.mesh = null;
this.points = null; this.points = null;
@ -56,6 +47,26 @@ class Widget {
this.settings = null; // used?? this.settings = null; // used??
this.modified = true; this.modified = true;
this.boundingBoxNeedsUpdate = true this.boundingBoxNeedsUpdate = true
// cache shadow geo
this.cache = {};
// geometry version for edge cache invalidation
this.geomVersion = 0;
this.stats = {
slice_time: 0,
load_time: 0,
progress: 0
};
// if this is a synthesized support widget
this.support = false;
// persisted: client annotations (cam tabs, fdm supports)
this.anno = {};
// persisted: file meta-data
this.meta = {
url: null,
file: null,
saved: false
};
// persisted: location state
this.track = { this.track = {
// box size for packer // box size for packer
box: { box: {
@ -80,62 +91,9 @@ class Widget {
}, },
top: 0, // z top top: 0, // z top
mirror: false, mirror: false,
indexed: false indexed: false,
}, indexRad: 0
// used to cache shadow geo
this.cache = {};
this.stats = {
slice_time: 0,
load_time: 0,
progress: 0
}; };
this.meta = {
url: null,
file: null,
saved: false
};
// if this is a synthesized support widget
this.support = false;
}
saveToCatalog(filename, overwrite) {
if (!filename) {
filename = this.meta.file;
}
if (this.grouped && !overwrite) {
return this;
}
const widget = this;
const mark = time();
const vertices = widget.getGeoVertices({ unroll: true }).slice();
widget.meta.file = filename;
widget.meta.save = mark;
catalog().putFile(filename, vertices, () => {
console.log("saved mesh ["+(vertices.length/3)+"] time ["+(time()-mark)+"]");
});
return this;
}
saveState(ondone) {
if (!ondone) {
clearTimeout(this._save_timer);
this._save_timer = setTimeout(() => {
this._save_timer = undefined;
this.saveState(() => {});
}, 1500);
return;
}
const widget = this;
index().put('ws-save-'+this.id, {
geo: widget.getGeoVertices({ unroll: false }).slice(),
track: widget.track,
group: this.group.id,
meta: this.meta,
anno: this.annotations()
}, result => {
widget.meta.saved = time();
if (ondone) ondone();
});
} }
annotations() { annotations() {
@ -231,9 +189,14 @@ class Widget {
return this.loadVertices(...arguments); return this.loadVertices(...arguments);
} }
setModified() { setModified(reason) {
this.modified = true; this.modified = true;
this.boundingBoxNeedsUpdate = true; this.boundingBoxNeedsUpdate = true;
this.clearShadows();
if (reason !== 'axis') {
this.cache.geo = undefined;
this.geomVersion++; // Increment version to invalidate edge cache
}
if (this.mesh && this.mesh.geometry) { if (this.mesh && this.mesh.geometry) {
// this fixes ray intersections after the mesh is modified // this fixes ray intersections after the mesh is modified
this.mesh.geometry.boundingSphere = null; this.mesh.geometry.boundingSphere = null;
@ -274,6 +237,8 @@ class Widget {
]); ]);
mesh.renderOrder = 1; mesh.renderOrder = 1;
// geometry.computeVertexNormals(); // geometry.computeVertexNormals();
// Clear existing groups (e.g., from cloned geometry) before adding new one
geometry.clearGroups();
geometry.addGroup(0, Infinity, 0); geometry.addGroup(0, Infinity, 0);
// mesh.castShadow = true; // mesh.castShadow = true;
// mesh.receiveShadow = true; // mesh.receiveShadow = true;
@ -311,23 +276,6 @@ class Widget {
this.slices = null; this.slices = null;
} }
/**
* @param {number} color
*/
setColor(color, settings, save = true) {
if (settings) {
console.trace('legacy call with settings');
}
if (Array.isArray(color)) {
color = color[this.getExtruder() % color.length];
}
if (save) {
this.color = color;
}
let material = this.getMaterial();
material.color.set(this.meta.disabled ? avgc(0x888888, color, 3) : color);
}
getColor() { getColor() {
return this.color; return this.color;
} }
@ -362,47 +310,6 @@ class Widget {
} }
} }
getVisualState() {
return {
edges: this.outline ? true : false,
wires: this.wire ? true : false,
opacity: this.getMaterial().opacity
};
}
setVisualState({ edges, wires, opacity}) {
this.cache.vizstate = this.getVisualState();
this.setEdges(edges ?? false);
this.setWireframe(wires ?? false);
this.setOpacity(opacity ?? 1);
}
refreshVisualState() {
this.setVisualState(this.getVisualState());
}
restoreVisualState() {
if (this.cache.vizstate) {
this.setVisualState(this.cache.vizstate);
}
}
/**
* @param {number} value
*/
setOpacity(value) {
const mesh = this.mesh;
const mat = this.getMaterial();
if (value <= 0.0) {
mat.transparent = solid_opacity < 1.0;
mat.opacity = solid_opacity;
mat.visible = false;
} else if (inRange(value, 0.0, solid_opacity)) {
mat.transparent = value < 1.0;
mat.opacity = value;
mat.visible = true;
}
}
toggleVisibility(bool) { toggleVisibility(bool) {
const mat = this.getMaterial(); const mat = this.getMaterial();
@ -463,7 +370,7 @@ class Widget {
// invalidate cached points // invalidate cached points
if (x || y || z) { if (x || y || z) {
this.points = null; this.points = null;
this.setModified(); this.setModified('moveMesh');
} }
} }
@ -480,11 +387,11 @@ class Widget {
} }
setAxisIndex(deg) { setAxisIndex(deg) {
// console.trace(deg);
let rad = deg * (Math.PI / 180); let rad = deg * (Math.PI / 180);
if (rad !== this.track.indexRad) { if (rad !== this.track.indexRad) {
this.track.indexRad = rad; this.track.indexRad = rad;
this.clearShadows(); this.setModified('axis');
this.setModified();
this._updateMeshPosition(); this._updateMeshPosition();
} }
} }
@ -537,27 +444,35 @@ class Widget {
pos.z += (z || 0); pos.z += (z || 0);
} }
if (x || y || z) { if (x || y || z) {
this.setModified(); this.setModified('_move');
this._updateMeshPosition(); this._updateMeshPosition();
} }
} }
_updateMeshPosition() { _updateMeshPosition() {
let mesh = this.mesh, let { cache, mesh, track } = this,
track = this.track, { top, tzoff } = track,
tzoff = track.tzoff, { x, y, z } = track.pos,
top = track.top, tz = -tzoff;
tz = -tzoff,
{ x, y, z } = track.pos;
if (track.indexed) { if (track.indexed) {
let rad = track.indexRad; this.mesh.rotation.x = -track.indexRad;
this.mesh.rotation.x = -rad;
z = top; z = top;
} else { } else {
this.mesh.rotation.x = 0; this.mesh.rotation.x = 0;
z += tz; z += tz;
} }
mesh.position.set(x, y, z); let { pos } = cache;
if (!pos || pos.x !== x || pos.y !==y || pos.z !== z) {
mesh.position.set(x, y, z);
this._updateEdges();
cache.pos = { x, y, z };
}
}
_updateEdges() {
if (this.outline && this.setEdges) {
this.setEdges(true);
}
} }
scale(x, y, z) { scale(x, y, z) {
@ -574,16 +489,14 @@ class Widget {
let mesh = this.mesh, let mesh = this.mesh,
scale = this.track.scale; scale = this.track.scale;
this.bounds = null; this.bounds = null;
this.setWireframe(false); if (this.setWireframe) this.setWireframe(false);
this.clearSlices(); this.clearSlices();
mesh.geometry.applyMatrix4(new THREE.Matrix4().makeScale(x, y, z)); mesh.geometry.applyMatrix4(new THREE.Matrix4().makeScale(x, y, z));
if (this.outline) { this._updateEdges();
this.setEdges(true);
}
scale.x *= (x || 1.0); scale.x *= (x || 1.0);
scale.y *= (y || 1.0); scale.y *= (y || 1.0);
scale.z *= (z || 1.0); scale.z *= (z || 1.0);
this.setModified(); this.setModified('_scale');
} }
rotate(x, y, z, temp, center = true) { rotate(x, y, z, temp, center = true) {
@ -593,9 +506,7 @@ class Widget {
if (center) { if (center) {
this.center(false); this.center(false);
} }
if (this.outline) { this._updateEdges();
this.setEdges(true);
}
if ((x || y || z) && this.api && this.api.event) { if ((x || y || z) && this.api && this.api.event) {
this.api.event.emit('widget.rotate', {widget: this, x, y, z}); this.api.event.emit('widget.rotate', {widget: this, x, y, z});
} }
@ -604,7 +515,7 @@ class Widget {
_rotate(x, y, z, temp) { _rotate(x, y, z, temp) {
if (!temp) { if (!temp) {
this.bounds = null; this.bounds = null;
this.setWireframe(false); if (this.setWireframe) this.setWireframe(false);
this.clearSlices(); this.clearSlices();
} }
let m4 = new THREE.Matrix4(); let m4 = new THREE.Matrix4();
@ -622,7 +533,7 @@ class Widget {
rot.y += (y || 0); rot.y += (y || 0);
rot.z += (z || 0); rot.z += (z || 0);
} }
this.setModified(); this.setModified('_rotate');
} }
// undo all accumulated rotations // undo all accumulated rotations
@ -632,8 +543,8 @@ class Widget {
}); });
this.roto = []; this.roto = [];
this.center(); this.center();
this.setModified(); this.setModified('unrotate');
this.refreshVisualState(); if (this.refreshVisualState) this.refreshVisualState();
} }
mirror() { mirror() {
@ -648,7 +559,7 @@ class Widget {
_mirror() { _mirror() {
this.clearSlices(); this.clearSlices();
this.setWireframe(false); if (this.setWireframe) this.setWireframe(false);
let geo = this.mesh.geometry, ot = this.track; let geo = this.mesh.geometry, ot = this.track;
let pos = geo.attributes.position; let pos = geo.attributes.position;
let arr = pos.array; let arr = pos.array;
@ -671,7 +582,7 @@ class Widget {
} }
pos.needsUpdate = true; pos.needsUpdate = true;
ot.mirror = !ot.mirror; ot.mirror = !ot.mirror;
this.setModified(); this.setModified('_mirror');
this.points = null; this.points = null;
} }
@ -694,6 +605,17 @@ class Widget {
getGeoVertices(opt = {}) { getGeoVertices(opt = {}) {
const { unroll, translate } = opt; const { unroll, translate } = opt;
let cacheKey = [
unroll ? 1 : 0,
translate ? 1 : 0,
((this.track.indexRad ?? 0) * 100000) | 0
].join(',');
let marked = Date.now();
let geoCache = this.cache.geo = (this.cache.geo || {});
let cached = geoCache[cacheKey];
if (cached) {
return cached.pos;
}
let geo = this.mesh.geometry; let geo = this.mesh.geometry;
let pos = geo.getAttribute('position'); let pos = geo.getAttribute('position');
// if indexed, return points rotated about X and then offset // if indexed, return points rotated about X and then offset
@ -703,6 +625,7 @@ class Widget {
.applyMatrix4( new THREE.Matrix4().makeRotationX(-track.indexRad) ); .applyMatrix4( new THREE.Matrix4().makeRotationX(-track.indexRad) );
} }
pos = pos.array; pos = pos.array;
// unroll indexed geometry
if (geo.index && unroll !== false) { if (geo.index && unroll !== false) {
let idx = geo.index.array; let idx = geo.index.array;
let len = idx.length; let len = idx.length;
@ -716,12 +639,8 @@ class Widget {
pp2[inc++] = pos[ip]; pp2[inc++] = pos[ip];
} }
pos = pp2; pos = pp2;
} else if (translate) {
pos = pos.slice();
} }
// used by CAM.shadowAt() geoCache[cacheKey] = { pos, marked };
this.cache.geo = pos;
delete this.cache.shadow;
return pos; return pos;
} }
@ -739,7 +658,7 @@ class Widget {
if (!this.bounds || refresh || this.boundingBoxNeedsUpdate) { if (!this.bounds || refresh || this.boundingBoxNeedsUpdate) {
this.bounds = new THREE.Box3().setFromArray(this.getGeoVertices({ this.bounds = new THREE.Box3().setFromArray(this.getGeoVertices({
translate: true, translate: true,
unroll: false unroll: true
})); }));
this.boundingBoxNeedsUpdate = false; this.boundingBoxNeedsUpdate = false;
} }
@ -782,65 +701,6 @@ class Widget {
return Date.now() - mark; return Date.now() - mark;
} }
setEdges(set) {
if (!(this.api && this.api.conf)) {
// missing api features in engine mode
return;
}
let mesh = this.mesh;
if (set && set.toggle) {
set = this.outline ? false : true;
}
if (this.outline) {
mesh.remove(this.outline);
this.outline = null;
}
if (set) {
let dark = this.api.space.is_dark();
let cam = this.api.mode.is_cam();
let color = dark ? 0x444444 : 0x888888;
let angle = this.api.conf.get().controller.edgeangle || 20;
let edges = new THREE.EdgesGeometry(mesh.geometry, angle);
let material = new THREE.LineBasicMaterial({ color });
this.outline = new THREE.LineSegments(edges, material);
this.outline.renderOrder = -20;
mesh.add(this.outline);
}
}
setWireframe(set, color, opacity) {
if (!(this.api && this.api.conf)) {
// missing api features in engine mode
return;
}
let mesh = this.mesh,
widget = this;
if (this.wire) {
this.setOpacity(solid_opacity);
mesh.remove(this.wire);
this.wire = null;
}
if (set) {
let dark = this.api.space.is_dark();
let mat = new THREE.MeshBasicMaterial({
wireframe: true,
color: dark ? 0xaaaaaa : 0,
opacity: 0.5,
transparent: true
})
let wire = widget.wire = new THREE.Mesh(mesh.geometry.shallowClone(), mat);
mesh.add(wire);
}
if (this.api.view.is_arrange()) {
this.setColor(this.color);
} else {
this.setColor(0x888888,undefined,false);
}
if (opacity !== undefined) {
widget.setOpacity(opacity);
}
}
show() { show() {
this.mesh.visible = true; this.mesh.visible = true;
@ -856,7 +716,12 @@ class Widget {
return this; return this;
} }
async shadowAt(z) { /**
* @param {number} z height for shadow computation
* @param {number | undefined} pocket normal value to match faces
* @returns {Polygon[]}
*/
async shadowAt(z, pocket) {
let shadows = this.cache.shadows; let shadows = this.cache.shadows;
if (!shadows) { if (!shadows) {
shadows = this.cache.shadows = {}; shadows = this.cache.shadows = {};
@ -868,20 +733,34 @@ class Widget {
// find closest shadow above and use to speed up delta shadow gen // find closest shadow above and use to speed up delta shadow gen
let zover = Object.keys(shadows).map(v => parseFloat(v)).filter(v => v > z); let zover = Object.keys(shadows).map(v => parseFloat(v)).filter(v => v > z);
let minZabove = Math.min(Infinity, ...zover); let minZabove = Math.min(Infinity, ...zover);
let shadow = this.#computeShadowAt(z - 0.005, minZabove); // shift shadow probeline down a fraction to capture z flats with FP noise
let shadow = this.#computeShadowAt(z - 0.005, minZabove, undefined, pocket);
if (minZabove < Infinity) { if (minZabove < Infinity) {
shadow = POLY.union([...shadow, ...shadows[minZabove]], 0.001, true, { wasm: false }); shadow = POLY.union([...shadow, ...shadows[minZabove]], 0, true, { wasm: false });
// cull interior sliver voids
if (!pocket)
for (let poly of shadow) {
if (poly.inner) {
poly.inner = poly.inner.filter(inr => {
let A = inr.area();
let P = inr.perimeter();
let R = (2 * A / P);
return R > 0.05;
});
}
}
} }
return shadows[z] = POLY.setZ(shadow, z); return shadows[z] = POLY.setZ(shadow, z);
} }
// create a stack of faces in 1mm increments // create a stack of faces in 1mm increments
// the stacks are then used to produce shadowlines // the stacks are then used to produce shadowlines
#ensureShadowCache() { #ensureShadowCache(pocket) {
if (this.cache.shadow) { if (this.cache.shadow) {
return; return this.cache.shadow;
} }
const geo = this.cache.geo; if (debug_shadow) console.time('shadow buckets');
const geo = this.getGeoVertices({ unroll: true, translate: true });
const length = geo.length; const length = geo.length;
const bounds = this.getBoundingBox(); const bounds = this.getBoundingBox();
const stack = {}; const stack = {};
@ -893,7 +772,8 @@ class Widget {
const b = new THREE.Vector3(geo[ip++], geo[ip++], geo[ip++]); const b = new THREE.Vector3(geo[ip++], geo[ip++], geo[ip++]);
const c = new THREE.Vector3(geo[ip++], geo[ip++], geo[ip++]); const c = new THREE.Vector3(geo[ip++], geo[ip++], geo[ip++]);
const n = THREE.computeFaceNormal(a, b, c); const n = THREE.computeFaceNormal(a, b, c);
if (n.z < 0.001) { // todo: use pocket to match normal values when set
if ((pocket && n.z > -pocket) || (!pocket && n.z < 0.001)) {
continue; continue;
} }
const minZ = Math.floor(Math.min(a.z, b.z, c.z)); const minZ = Math.floor(Math.min(a.z, b.z, c.z));
@ -902,15 +782,55 @@ class Widget {
stack[z].push(a, b, c); stack[z].push(a, b, c);
} }
} }
this.cache.shadow = stack; if (debug_shadow) console.timeEnd('shadow buckets');
return this.cache.shadow = stack;
}
async computeShadowStack(zlist, progress, pocket) {
let shadow_stack = this.cache.shadow_stack;
if (!shadow_stack) {
shadow_stack = this.cache.shadow_stack = {};
}
let work = self.kiri_worker;
let stack = this.#ensureShadowCache(pocket);
let plist = [];
if (debug_shadow) console.time('seed minion buckets');
work.minions.broadcast('cam_shadow_stack', stack);
if (debug_shadow) console.timeEnd('seed minion buckets');
let pinc = 1 / zlist.length;
let pval = 0;
for (let z of zlist) {
let p = work.minions.queueAsync({
cmd: 'cam_shadow_z',
z: z - 0.005,
t: z + 1
}).then(reply => {
shadow_stack[z - 0.005] = decode(reply.data);
pval += pinc;
progress(pval);
});
plist.push(p);
}
await Promise.all(plist);
work.minions.broadcast('cam_shadow_stack', { clear: true });
}
// called from minion
computeShadowAtZ(z, ztop, cached, pocket) {
return this.#computeShadowAt(z, ztop, cached, pocket);
} }
// union triangles > z (opt cap < ztop) into polygon(s) // union triangles > z (opt cap < ztop) into polygon(s)
// slice the triangle stack matchingg z then union the results // slice the triangle stack matchingg z then union the results
#computeShadowAt(z, ztop) { #computeShadowAt(z, ztop, cached, pocket) {
this.#ensureShadowCache(); let shadow_stack = this.cache.shadow_stack;
if (shadow_stack && shadow_stack[z]) {
return shadow_stack[z];
}
let label = `compute shadow ${z.round(2)}`;
if (debug_shadow) console.time(label);
const found = []; const found = [];
const stack = this.cache.shadow; const stack = cached ?? this.#ensureShadowCache(pocket);
let minZ = Math.floor(z); let minZ = Math.floor(z);
let maxZ = Math.ceil(z); let maxZ = Math.ceil(z);
let slices = []; let slices = [];
@ -965,84 +885,18 @@ class Widget {
}); });
// recursively merge grid constrained subsets of polygons // recursively merge grid constrained subsets of polygons
polys = unionTris(polys); polys = POLY.unionFaces(polys);
// for a more perfect union, pump shadows to merge very close lines // for a more perfect union, pump shadows to merge very close lines
// todo: create clipper only version that avoids round trip thru geo classes // todo: create clipper only version that avoids round trip thru geo classes
polys = POLY.offset(polys, 0.01); polys = POLY.offset(polys, 0.01);
polys = POLY.offset(polys, -0.01); polys = POLY.offset(polys, -0.01);
if (debug_shadow) console.timeEnd(label);
return polys; return polys;
} }
} }
/*
* recursively process triangle soup from mesh by bucketing them
* by grid cell locality with max depth. this minimizes the number
* of progressive small unions. results in a few unions of larger polys.
*/
function unionTris(polys, depth = 0) {
if (depth < 5 && polys.length > 1000) {
// Calculate bounding box of all triangles
let minX = Infinity, maxX = -Infinity;
let minY = Infinity, maxY = -Infinity;
for (let tri of polys) {
for (let pt of tri.points) {
minX = Math.min(minX, pt.x);
maxX = Math.max(maxX, pt.x);
minY = Math.min(minY, pt.y);
maxY = Math.max(maxY, pt.y);
}
}
// Create NxN grid
const gridSize = 3;
const cellWidth = (maxX - minX) / gridSize;
const cellHeight = (maxY - minY) / gridSize;
// Partition polygons into grid cells
const grid = Array(gridSize).fill(0).map(() => Array(gridSize).fill(0).map(() => []));
for (let i = 0; i < polys.length; i++) {
const poly = polys[i];
// Use polygon centroid to assign to cell
const bounds = poly.bounds;
const cx = (bounds.minx + bounds.maxx) / 2;
const cy = (bounds.miny + bounds.maxy) / 2;
// Clamp to grid bounds
let gx = Math.floor((cx - minX) / cellWidth);
let gy = Math.floor((cy - minY) / cellHeight);
gx = Math.max(0, Math.min(gridSize - 1, gx));
gy = Math.max(0, Math.min(gridSize - 1, gy));
grid[gx][gy].push(poly);
}
// Union within each cell
const cellResults = [];
let totalInCell = 0;
for (let gx = 0; gx < gridSize; gx++) {
for (let gy = 0; gy < gridSize; gy++) {
const cellPolys = grid[gx][gy];
if (cellPolys.length > 0) {
totalInCell += cellPolys.length;
const cellUnion = unionTris(cellPolys, depth + 1);
cellResults.push(...cellUnion);
}
}
}
// Union the cell results
polys = unionTris(cellResults, depth + 1);
} else {
polys = POLY.union(polys, 0, true, { wasm: false });
}
return polys;
}
// Widget Grouping API // Widget Grouping API
const Group = Widget.Groups = { const Group = Widget.Groups = {
list() { list() {
@ -1143,40 +997,8 @@ const Group = Widget.Groups = {
} }
}; };
Widget.loadFromCatalog = function(filename, ondone) { function newWidget(id,group) {
catalog().getFile(filename, function(data) { return new Widget(id,group);
let widget = newWidget().loadVertices(data); }
widget.meta.file = filename;
ondone(widget);
});
};
Widget.loadFromState = function(id, ondone, move) {
index().get('ws-save-'+id, function(data) {
if (data) {
let vertices = data.geo || data,
track = data.track || undefined,
group = data.group || id,
anno = data.anno || undefined,
widget = newWidget(id, Group.forid(group)),
meta = data.meta || widget.meta,
ptr = widget.loadVertices(vertices);
widget.meta = meta;
widget.anno = anno || widget.anno;
// restore widget position if specified
if (move && track && track.pos) {
widget.track = track;
widget.move(track.pos.x, track.pos.y, track.pos.z, true);
}
ondone(ptr);
} else {
ondone(null);
}
});
};
Widget.deleteFromState = function(id,ondone) {
index().remove('ws-save-'+id, ondone);
};
export { Widget, newWidget }; export { Widget, newWidget };

View file

@ -1,43 +0,0 @@
{
"pre":[
"M117 Heating ; announce heating",
"M107 ; turn off filament cooling fan",
"M104 S{temp} T0 ; set extruder temperature",
"M140 S{bed_temp} T0 ; set bed temperature",
"G90 ; set absolute positioning mode",
"M83 ; set relative positioning for extruder",
"G28 ; home axes",
"G1 F3000 ; set default move speed",
"M190 S{bed_temp} T0 ; wait for bed to reach target temp",
"M109 S{temp} T0 ; wait for extruder to reach target temp",
"M117 Purge ; announce purge",
"G92 E0 ; set extruder position to 0",
"G1 E20 F250 ; purge 20mm from extruder",
"G1 X0 Y0 Z0 E1 F3000 ; move to origin / wipe",
"M117 Start Print ; announce start of print"
],
"post":[
"M117 Done ; announce done",
"M107 ; turn off filament cooling fan",
"M104 S0 T0 ; turn off extruder",
"M140 S0 T0 ; turn off bed",
"G91 ; relative movement",
"G1 Z10 F1200 ; drop bed 10mm",
"G90 ; absolute positioning",
"G28 X0 Y0 ; home XY axes",
"M84 ; disable stepper motors"
],
"cmd":{
"fan_power": "M106 S{fan_speed}",
"progress": "M117 {progress}% layer {layer} / {layers}"
},
"settings":{
"image_url": "/decal/gridbot.png",
"image_anchor": 0,
"image_scale": 1,
"origin_center": false,
"bed_width": 300,
"bed_depth": 300,
"build_height": 300
}
}

View file

@ -1,43 +0,0 @@
{
"pre":[
"M117 Heating ; announce heating",
"M107 ; turn off filament cooling fan",
"M104 S{temp} T0 ; set extruder temperature",
"M140 S{bed_temp} T0 ; set bed temperature",
"G90 ; set absolute positioning mode",
"M83 ; set relative positioning for extruder",
"G28 ; home axes",
"G1 F3000 ; set default move speed",
"M190 S{bed_temp} T0 ; wait for bed to reach target temp",
"M109 S{temp} T0 ; wait for extruder to reach target temp",
"M117 Purge ; announce purge",
"G92 E0 ; set extruder position to 0",
"G1 E20 F250 ; purge 20mm from extruder",
"G1 X0 Y0 Z0 E1 F3000 ; move to origin / wipe",
"M117 Start Print ; announce start of print"
],
"post":[
"M117 Done ; announce done",
"M107 ; turn off filament cooling fan",
"M104 S0 T0 ; turn off extruder",
"M140 S0 T0 ; turn off bed",
"G91 ; relative movement",
"G1 Z10 F1200 ; drop bed 10mm",
"G90 ; absolute positioning",
"G28 X0 Y0 ; home XY axes",
"M84 ; disable stepper motors"
],
"cmd":{
"fan_power": "M106 S{fan_speed}",
"progress": "M117 {progress}% layer {layer} / {layers}"
},
"settings":{
"image_url": "/decal/gridbot.png",
"image_anchor": 0,
"image_scale": 1,
"origin_center": false,
"bed_width": 300,
"bed_depth": 300,
"build_height": 300
}
}

View file

@ -0,0 +1,400 @@
{
"mode": "FDM",
"internal": 0,
"bedHeight": 2.5,
"bedWidth": 270,
"bedDepth": 270,
"bedRound": false,
"maxHeight": 270,
"originCenter": false,
"gcodeFan": [
";; >-----> FAN >------------------------>",
"M106 P1 S{fan_speed} ; set fan speed",
";; <-----< FAN <------------------------<"
],
"gcodeTrack": [
";; >-----> PROGRESS >------------------->",
"M73 P{progress} R{remain_time} ; report progress % and remaining time",
";; <-----< PROGRESS <-------------------<"
],
"gcodeLayer": [
";; >-----> LAYER >---------------------->",
";BEFORE_LAYER_CHANGE",
";[layer_z]",
"G92 E0",
"TIMELAPSE_TAKE_FRAME",
"DEFECT_DETECTION_DETECT",
";AFTER_LAYER_CHANGE",
";[layer_z]",
"SET_PRINT_STATS_INFO TOTAL_LAYER={layers}",
"SET_PRINT_STATS_INFO CURRENT_LAYER={layer}",
";; <-----< LAYER <----------------------<"
],
"gcodePre": [
";; >-----> HEADER >--------------------->",
";; PREAMBLE POST",
"; HEADER_BLOCK_START",
"; generated by Kiri:Moto",
"; total layer number: {layers}",
"; filament_density: 1.24,1.24,1.24,1.24",
"; filament_diameter: 1.75,1.75,1.75,1.75",
"; max_z_height: {z_max}",
"; HEADER_BLOCK_END",
"; EXECUTABLE_BLOCK_START",
"M73 P0 R1 ; set print progress to 0%, remaining time 1 min",
"M106 S0 ; turn off primary cooling fan",
"M106 P2 S0 ; turn off auxiliary/part fan (fan index 2)",
"PRINT_START ; firmware macro: begin print sequence",
"DEFECT_DETECTION_START ; start bed surface defect detection system",
"SET_PRINT_STATS_INFO TOTAL_LAYER=3 ; set total layer count to 3",
"SET_PRINT_STATS_INFO CURRENT_LAYER=0 ; initialize current layer to 0",
"TIMELAPSE_START ; start timelapse capture",
"M140 S65 ; set bed temperature to 65C (no wait)",
"M104 T{tool} S140 ; set tool 0 nozzle temp to 140C (no wait)",
"M204 S10000 ; set default acceleration",
"G28 X Y ; home X and Y axes",
";===== Bed surface foreign-object detection ========",
"T{tool} ; select tool 0",
"G90 ; absolute positioning",
"DEFECT_DETECTION_DETECT_BED ; scan bed for debris or foreign objects",
";===== Tool pickup / toolhead detection =================",
"SM_PRINT_CHECK_SWITCH_EXTRUDER ; verify extruder/tool switch state",
";===== Auto feed, flow calibration & pre-extrusion ======================",
"SM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140 ; preheat extruder 1",
"SM_PRINT_AUTO_FEED EXTRUDER=0 ; auto-load filament for extruder 0",
"SM_PRINT_FLOW_CALIBRATE EXTRUDER=0 ; calibrate flow for extruder 0",
"SM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140 ; preheat extruder 2",
"SM_PRINT_AUTO_FEED EXTRUDER=1 ; auto-load filament for extruder 1",
"SM_PRINT_FLOW_CALIBRATE EXTRUDER=1 ; calibrate flow for extruder 1",
"SM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140 ; preheat extruder 3",
"SM_PRINT_AUTO_FEED EXTRUDER=2 ; auto-load filament for extruder 2",
"SM_PRINT_FLOW_CALIBRATE EXTRUDER=2 ; calibrate flow for extruder 2",
"SM_PRINT_AUTO_FEED EXTRUDER=3 ; auto-load filament for extruder 3",
"SM_PRINT_FLOW_CALIBRATE EXTRUDER=3 ; calibrate flow for extruder 3",
"M104 S0 T0 A0 ; disable heater on tool 0",
"M104 S0 T1 A0 ; disable heater on tool 1",
"M104 S0 T2 A0 ; disable heater on tool 2",
"M104 S0 T3 A0 ; disable heater on tool 3",
"M104 T{tool} S130 ; set first tool nozzle temp to 130C",
";===== Coarse homing =================",
"T{tool} ; select first tool",
"M106 S255 ; turn primary fan to full speed",
"M106 P2 S0 ; ensure auxiliary fan is off",
"MOVE_TO_DISCARD_FILAMENT_POSITION ; move to filament discard / purge location",
"M109 T{tool} S130 ; wait for nozzle to reach 130C",
"ROUGHLY_CLEAN_NOZZLE_WITH_DISCARD ; coarse nozzle cleaning with discard",
"MOVE_TO_XY_IDLE_POSITION_EXTRUDER ; move toolhead to idle XY position",
"G28 Z I140 J140 ; home Z axis with specified probe offsets",
";===== Build plate detection =================",
"DETECT_BED_PLATE ; detect presence/type of build plate",
";===== Deep nozzle cleaning =================",
"G90 ; absolute positioning",
"G0 Z5 F10000 ; raise Z to safe height",
"MOVE_TO_DISCARD_FILAMENT_POSITION ; move to discard position",
"M109 S170 ; heat nozzle to deep-clean temperature",
"ROUGHLY_CLEAN_NOZZLE ; coarse nozzle cleaning",
"MOVE_TO_XY_IDLE_POSITION_EXTRUDER ; move to idle position",
"FINELY_CLEAN_NOZZLE_STAGE_1 ; fine nozzle cleaning stage 1",
"M104 S130 ; lower nozzle temperature",
"G0 Z5 F10000 ; raise Z",
"MOVE_TO_DISCARD_FILAMENT_POSITION ; move to discard position again",
"ROUGHLY_CLEAN_NOZZLE ; repeat coarse cleaning",
"MOVE_TO_XY_IDLE_POSITION_EXTRUDER ; move to idle position",
"FINELY_CLEAN_NOZZLE_STAGE_2 ; fine nozzle cleaning stage 2",
";===== Precision homing =================",
"M106 S255 ; set primary fan to full speed",
"M109 S130 ; wait for nozzle at 130C",
"M190 S65 ; wait for bed to reach 65C",
"M107 P2 ; turn off auxiliary fan",
"G90 ; absolute positioning",
"G0 Z5 F10000 ; lift Z before homing",
"G28 Z ; precise Z homing",
";===== Heated bed leveling =================",
"BED_MESH_CALIBRATE PROBE_COUNT=11,11 ; probe bed mesh with 11x11 grid",
";===== Draw purge / start line =================",
"G90 ; absolute positioning",
"G1 Z1.5 ; move nozzle near bed",
"G0 X10 Y3 Z2 F18000 ; move to purge start position",
"M109 S220 ; heat nozzle to print temperature",
"G1 Z0.2 ; move to purge height",
"M83 ; relative extrusion mode",
"G1 X110 E15 F360 ; extrude purge line",
"G1 Z1.5 ; lift nozzle",
"M73 P20 R1 ; update progress to 20%",
"G90 ; absolute positioning",
"M106 S0 ; ensure primary fan off",
"G90 ; absolute positioning (redundant safety)",
"G21 ; set units to millimeters",
"M83 ; relative extrusion mode",
"; filament start gcode",
"SET_PRESSURE_ADVANCE ADVANCE=0.02 ; override pressure advance value",
"T{tool} ; select first tool",
"M106 S0 ; ensure primary fan off",
"M106 P2 S0 ; ensure auxiliary fan off",
";; <-----< HEADER <---------------------<"
],
"gcodePost": [
";; >-----> FOOTER >--------------------->",
"M106 S0 ; turn off primary cooling fan",
"M106 P2 S0 ; turn off auxiliary/part fan (fan index 2)",
"PRINT_END",
"TIMELAPSE_STOP",
"M73 P100 R0 ; report progress complete",
";; <-----< FOOTER <---------------------<"
],
"gcodeProc": "",
"gcodeFExt": "gcode",
"extruders": [
{
"extFilament": 1.75,
"extNozzle": 0.4,
"extSelect": [
"; BEGIN tool change from {last_tool} to {tool}",
"M620 S{tool}A ; prepare tool change",
"M204 S9000 ; Set acceleration to 9000 mm/s^2",
"M620.11 S0 ; Reset filament retraction state",
"M400 ; wait for moves to complete",
"M620.1 E F187 T240 ; set purge rate 187 temp 240",
"T{tool} ; initiate tool change",
"M620.1 E F374 T240 ; set purge rate 374 temp 240",
"M620.11 S0 ; Reset filament retraction state",
"G92 E0 ; reset extruder position",
"M400 ; wait for moves to complete",
" ; -- PURGE --",
"M109 S240 ; Set nozzle temperature to 240°C",
"G1 E23.7 F187 ; Extrude 23.7mm of filament at flowrate 187",
"G1 E0.914611 F50 ; Pulsatile extrusion",
"G1 E10.518 F187 ; Continue extrusion at flowrate 187",
"G1 E0.914611 F50 ; Pulsatile extrusion",
"G1 E10.518 F374 ; Continue extrusion at flowrate 374",
"G1 E0.914611 F50 ; Pulsatile extrusion",
"G1 E10.518 F374 ; Continue extrusion at flowrate 374",
"G1 E0.914611 F50 ; Pulsatile extrusion",
"G1 E10.518 F374 ; Continue extrusion at flowrate 374",
"G1 E-2 F1800 ; Retract filament by 2mm",
"G1 E2 F300 ; Recover retraction slowly",
"M400 ; Wait for all movements to complete",
"M109 S{temp} ; Set nozzle temperature back to print temp",
" ; -- SHAKE --",
"M106 P1 S255 ; Turn on nozzle cooling fan to full speed",
"M400 S3 ; Wait for all movements to complete",
"G1 X70 F5000 ; Move to X70",
"G1 X90 F3000 ; Move to X90",
"G1 Y255 F4000 ; Move to Y255",
"G1 X105 F5000 ; Move to X105",
"G1 Y265 ; Move to Y265",
"G1 X70 F10000 ; Rapid move to X70",
"G1 X100 F5000 ; Move to X100",
"G1 X70 F10000 ; Rapid move back to X70",
"G1 X100 F5000 ; Move to X100",
"G1 X70 F10000 ; Shake filament residue",
"G1 X80 F15000",
"G1 X60",
"G1 X80",
"G1 X60",
"G1 X80 ; Shake to put down garbage",
"G1 X100 F5000 ; Move to X100",
"G1 X165 F15000 ; Wipe and shake",
"G1 Y256 ; Move Y aside to prevent collision",
"M400 ; Wait for all movements to complete",
"M204 S10000 ; Set acceleration to 10000 mm/s^2",
"M621 S{tool}A ; complete tool change",
"; END tool change"
],
"extOffsetX": 0,
"extOffsetY": 0,
"extDeselect": []
},
{
"extFilament": 1.75,
"extNozzle": 0.4,
"extSelect": [],
"extOffsetX": 0,
"extOffsetY": 0,
"extDeselect": []
},
{
"extFilament": 1.75,
"extNozzle": 0.4,
"extSelect": [],
"extOffsetX": 0,
"extOffsetY": 0,
"extDeselect": []
},
{
"extFilament": 1.75,
"extNozzle": 0.4,
"extSelect": [],
"extOffsetX": 0,
"extOffsetY": 0,
"extDeselect": []
}
],
"new": false,
"deviceName": "My Snapmaker U1",
"bedBelt": false,
"fwRetract": false,
"filamentSource": "direct",
"extras": {
"bbl": {
"luke": {
"host": "10.20.20.75",
"code": "20709223",
"serial": "01P09C482401767",
"modified": true,
"selected": false,
"type": "C12"
},
"leia": {
"host": "10.20.20.76",
"code": "35129782",
"serial": "01P09C461603301",
"modified": true,
"selected": false,
"type": "C12"
},
"vader": {
"host": "10.20.20.77",
"code": "17711811",
"serial": "01P00A413100516",
"modified": true,
"selected": false,
"type": "C12"
},
"Scrappy": {
"host": "10.10.2.5",
"serial": "01S00A292500224",
"code": "38373335",
"modified": true,
"type": "C11",
"selected": true
}
}
},
"imageURL": "",
"deviceZMax": 0,
"gcodeTime": 1,
"gcodeFeature": [],
"profiles": [
{
"processName": "U1 PLA",
"sliceHeight": 0.2,
"sliceShells": 3,
"sliceShellOrder": "out-in",
"sliceLayerStart": "last",
"sliceFillAngle": 45,
"sliceFillOverlap": 0.4,
"sliceFillSparse": 0.2,
"sliceFillType": "linear",
"sliceAdaptive": false,
"sliceMinHeight": 0,
"sliceSupportDensity": 0.2,
"sliceSupportOffset": 0.2,
"sliceSupportGap": 1,
"sliceSupportExtra": 0,
"sliceSupportAngle": 50,
"sliceSupportNozzle": 0,
"sliceSolidMinArea": 1,
"sliceBottomLayers": 2,
"sliceTopLayers": 3,
"firstLayerRate": 20,
"firstLayerYOffset": 0,
"firstLayerBrim": 0,
"firstLayerBeltLead": 0,
"firstLayerFanSpeed": 0,
"outputTemp": 220,
"outputBedTemp": 65,
"outputFanSpeed": 255,
"outputFeedrate": 100,
"outputFinishrate": 90,
"outputSeekrate": 200,
"outputShellMult": 1.2,
"outputFillMult": 1.18,
"outputSparseMult": 1.25,
"outputRetractDist": 1.3,
"outputRetractSpeed": 30,
"outputRetractWipe": 0,
"outputRetractDwell": 5,
"outputShortPoly": 0,
"outputMinSpeed": 1,
"outputCoastDist": 0,
"outputLayerRetract": false,
"zHopDistance": 0,
"antiBacklash": 0,
"sliceFillWidth": 1,
"firstSliceHeight": 0.2,
"firstLayerFillRate": 40,
"firstLayerNozzleTemp": 220,
"firstLayerBedTemp": 65,
"firstLayerBrimTrig": 0,
"firstLayerBrimGap": 0,
"outputRaft": false,
"outputRaftSpacing": 0.2,
"outputBrimCount": 0,
"outputBrimOffset": 1,
"outputPurgeTower": 0,
"outputInvertX": false,
"outputInvertY": false,
"ranges": [],
"sliceLineWidth": 0,
"sliceFillRepeat": 2,
"firstLayerBrimIn": 0,
"firstLayerBeltBump": 0,
"outputBeltFirst": false,
"outputLoops": 0,
"sliceFillGrow": 0.2,
"sliceSupportSpan": 5,
"sliceSupportOutline": true,
"firstLayerFlatten": 0,
"outputDraftShield": false,
"outputAvoidGaps": true,
"sliceDetectThin": "off",
"outputAlternating": false,
"sliceLayerStartX": 0,
"sliceLayerStartY": 0,
"outputFanLayer": 1,
"sliceAngle": 45,
"sliceZInterleave": false,
"outputScarfLength": 0,
"outputMinLayerTime": 9,
"sliceCompInner": 0,
"sliceCompOuter": 0,
"outputMaxFlowrate": 15,
"firstLayerBeltFact": 1,
"sliceSolidify": false,
"fdmArcEnabled": false,
"fdmArcResolution": 1,
"fdmArcTolerance": 0.005,
"sliceSupportType": "manual",
"sliceSupportTree": false,
"sliceSolidMinThick": 0.4
}
],
"gcodeChange": [
";; >-----> TOOL >----------------------->",
"; Change Tool{last_tool} -> Tool{tool} (layer {layer}) ; tool change",
"M104 S70 T{last_tool} ; cooldown previous tool",
"G91 ; set relative positioning",
"G1 Z1.5 ; lift nozzle 1.5 mm to avoid collision",
"G90 ; return to absolute positioning",
"G1 F21000 ; set high travel feedrate",
"M104 S{temp} T{tool} ; heat new tool and wait",
"M400 ; wait for all queued moves to finish",
"T{tool} ; select tool {tool}",
"SM_PRINT_PREEXTRUDE_FILAMENT INDEX={tool} ; Snapmaker macro: pre-extrude tool filament",
"G90 ; ensure absolute positioning",
"M106 P2 S178 ; set auxiliary/part fan to ~70%",
"M106 S255 ; set primary cooling fan to full speed",
"M109 S{temp} T{tool} ; set new tool nozzle temperature",
"SET_PRESSURE_ADVANCE ADVANCE=0.02 ; override pressure advance value for tool 1",
"G1 F30000 ; set printing feedrate",
";ADD WIPE TOWER HERE",
"M220 R ; reset feedrate override to default (100%) / restore",
"G1 F30000 ; set travel feedrate to 30000 mm/min",
"G4 S0 ; dwell 0s (no-op / sync point)",
"G92 E0 ; zero extruder position after purge (prevents huge E values)",
"G1 X{pos_x} Y{pos_y} ; restore XY position",
"G1 Z{pos_z} ; restore Z position",
";; <-----< TOOL <-----------------------<"
]
}

View file

@ -2,8 +2,8 @@
// UI FRONT END ANIMATION CODE for 2D // UI FRONT END ANIMATION CODE for 2D
import { api } from '../../core/api.js'; import { api } from '../../../app/api.js';
import { space } from '../../../moto/space.js'; import { space } from '../../../../moto/space.js';
const { client } = api; const { client } = api;

View file

@ -2,8 +2,8 @@
// UI FRONT END ANIMATION CODE for 3D (indexed) // UI FRONT END ANIMATION CODE for 3D (indexed)
import { api } from '../../core/api.js'; import { api } from '../../../app/api.js';
import { space } from '../../../moto/space.js'; import { space } from '../../../../moto/space.js';
const { client } = api; const { client } = api;

View file

@ -1,8 +1,8 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */ /** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { api } from '../../core/api.js'; import { api } from '../../../app/api.js';
import { env, opAdd, opRender } from './client.js'; import { env, opAdd, opRender } from './init-ui.js';
import { CAM } from './driver-fe.js'; import { CAM } from './dispatch.js';
import { clearTabs, restoreTabs } from './cl-tab.js'; import { clearTabs, restoreTabs } from './cl-tab.js';
import { updateStock } from './cl-stock.js'; import { updateStock } from './cl-stock.js';

View file

@ -1,6 +1,6 @@
import { api } from "../../core/api.js"; import { api } from '../../../app/api.js';
import { CAM } from "./driver-fe.js"; import { CAM } from "./dispatch.js";
import { clearPops, env } from "./client.js"; import { clearPops, env } from "./init-ui.js";
let alert, lastWidget; let alert, lastWidget;

View file

@ -1,8 +1,8 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */ /** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { api } from '../../core/api.js'; import { api } from '../../../app/api.js';
import { env, clearPops } from './client.js'; import { env, clearPops } from './init-ui.js';
import { CAM } from './driver-fe.js'; import { CAM } from './dispatch.js';
export let holeSelOn = false; export let holeSelOn = false;
export let lastSelHoles; export let lastSelHoles;

View file

@ -1,14 +1,16 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */ /** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { $, h } from '../../../moto/webui.js'; import { $, h } from '../../../../moto/webui.js';
import { api } from '../../core/api.js'; import { api } from '../../../app/api.js';
import { env, opRender } from './client.js'; import { env, opRender } from './init-ui.js';
import { Tool } from './tool.js'; import { Tool } from '../core/tool.js';
import { opFlip } from './cl-flip.js'; import { opFlip } from './cl-flip.js';
import { selectHoles } from './cl-hole.js'; import { selectHoles } from './cl-hole.js';
import { selectHelical } from './cl-helical.js'; import { selectHelical } from './cl-helical.js';
import { surfaceAdd } from './cl-surface.js'; import { surfaceAdd } from './cl-surface.js';
import { traceAdd } from './cl-trace.js'; import { traceAdd, traceClear, traceLoad } from './cl-trace.js';
import { startFaceUp } from '../../../app/face-tool.js';
import { showZBottom, showZTop, updateStock } from './cl-stock.js';
const { MODES } = api.const; const { MODES } = api.const;
const { uc: UC } = api; const { uc: UC } = api;
@ -27,6 +29,10 @@ function hasSpindle() {
return env.current.device.spindleMax > 0; return env.current.device.spindleMax > 0;
} }
function isWebGPU() {
return api.conf.get().controller.webGPU;
}
function zTop() { function zTop() {
return api.conf.get().process.camZTop > 0; return api.conf.get().process.camZTop > 0;
} }
@ -131,10 +137,21 @@ export function createPopOp(type, map) {
return rec; return rec;
}, },
hideshow: () => { hideshow: () => {
let conf = api.conf.get();
for (let inp of Object.values(op.inputs)) { for (let inp of Object.values(op.inputs)) {
let parent = inp.parentElement; let parent = inp.parentElement;
if (parent && parent.setVisible && parent.__opt.show) { if (parent && parent.setVisible && parent.__opt.show) {
parent.setVisible(parent.__opt.show(op, api.conf.get())); // for normal inputs
parent.setVisible(parent.__opt.show(op, conf));
} else if (inp.__opt?.show) {
// for blanks
inp.setVisible(inp.__opt.show(op, conf));
} else if (inp.children) {
for (let c of inp.children) {
if (c.__opt?.show) {
c.setVisible(c.__opt.show(op, conf));
}
}
} }
} }
}, },
@ -194,6 +211,82 @@ export function createPopOp(type, map) {
} }
export function createPopOps() { export function createPopOps() {
const bottom = true;
const open = true;
const top = true;
function selector(type, { input, event, opt }) {
let which = opt?.top ? 'top' : opt?.bottom ? 'bottom' : 'unknown';
let value = parseFloat(input.value);
switch (type) {
case 'click':
let rec = env.poppedRec;
let { field } = opt;
api.event.emit('cam.zplane.select', { which, onselect(value) {
rec[field] = value;
input.focus();
input.value = value;
input.blur();
} } );
break;
case 'enter':
api.event.emit('cam.zplane.show', { which, value });
break;
case 'leave':
api.event.emit('cam.zplane.hide', { which });
break;
}
}
function isClear() {
return env.poppedRec.mode === 'clear';
}
function isTrace() {
return env.poppedRec.mode === 'trace';
}
function isShadow() {
return env.poppedRec.shadow;
}
function isSurface() {
return env.poppedRec.mode === 'surface';
}
function isSurfaceLinear() {
return env.poppedRec.mode === 'surface' && env.poppedRec.sr_type === 'linear';
}
function canDogBones() {
if (!env.poppedRec) return false;
return env.poppedRec.mode === 'follow';
}
function canDogBonesRev() {
return canDogBones() && env.poppedRec.dogbone;
}
function zDogSep() {
return canDogBones() || zBottom();
}
const ov_topz = { title: LANG.ou_ztop_l, convert: toFloat, units, selector, top, field: "ov_topz" };
const ov_botz = { title: LANG.ou_zbot_l, convert: toFloat, units, selector, bottom, field: "ov_botz" };
const ov_hover = {
hover(ie) {
if (ie === 'enter') {
showZTop(env.poppedRec.ov_topz);
showZBottom(env.poppedRec.ov_botz);
} else {
updateStock();
}
}
};
createPopOp('level', { createPopOp('level', {
tool: 'camLevelTool', tool: 'camLevelTool',
spindle: 'camLevelSpindle', spindle: 'camLevelSpindle',
@ -236,10 +329,6 @@ export function createPopOps() {
tool: UC.newSelect(LANG.cc_tool, {}, "tools"), tool: UC.newSelect(LANG.cc_tool, {}, "tools"),
direction: UC.newSelect(LANG.ou_dire_s, { title: LANG.ou_dire_l }, "direction"), direction: UC.newSelect(LANG.ou_dire_s, { title: LANG.ou_dire_l }, "direction"),
sep: UC.newBlank({ class: "pop-sep" }), sep: UC.newBlank({ class: "pop-sep" }),
spindle: UC.newInput(LANG.cc_spnd_s, { title: LANG.cc_spnd_l, convert: toInt, show: hasSpindle }),
rate: UC.newInput(LANG.cc_feed_s, { title: LANG.cc_feed_l, convert: toInt, units }),
plunge: UC.newInput(LANG.cc_plng_s, { title: LANG.cc_plng_l, convert: toInt, units }),
sep: UC.newBlank({ class: "pop-sep" }),
step: UC.newInput(LANG.cc_sovr_s, { title: LANG.cc_sovr_l, convert: toFloat, bound: UC.bound(0.01, 1.0) }), step: UC.newInput(LANG.cc_sovr_s, { title: LANG.cc_sovr_l, convert: toFloat, bound: UC.bound(0.01, 1.0) }),
down: UC.newInput(LANG.cc_sdwn_s, { title: LANG.cc_sdwn_l, convert: toFloat, units }), down: UC.newInput(LANG.cc_sdwn_s, { title: LANG.cc_sdwn_l, convert: toFloat, units }),
leave: UC.newInput(LANG.cr_lsto_s, { title: LANG.cr_lsto_l, convert: toFloat, units }), leave: UC.newInput(LANG.cr_lsto_s, { title: LANG.cr_lsto_l, convert: toFloat, units }),
@ -250,9 +339,14 @@ export function createPopOps() {
inside: UC.newBoolean(LANG.cr_olin_s, undefined, { title: LANG.cr_olin_l, show: () => !env.poppedRec.all || env.poppedRec.inside }), inside: UC.newBoolean(LANG.cr_olin_s, undefined, { title: LANG.cr_olin_l, show: () => !env.poppedRec.all || env.poppedRec.inside }),
omitthru: UC.newBoolean(LANG.co_omit_s, undefined, { title: LANG.co_omit_l }), omitthru: UC.newBoolean(LANG.co_omit_s, undefined, { title: LANG.co_omit_l }),
sep: UC.newBlank({ class: "pop-sep" }), sep: UC.newBlank({ class: "pop-sep" }),
exp: UC.newExpand("overrides"), exp: UC.newExpand("feeds & speeds", { }),
ov_topz: UC.newInput(LANG.ou_ztop_s, { title: LANG.ou_ztop_l, convert: toFloat, units }), spindle: UC.newInput(LANG.cc_spnd_s, { title: LANG.cc_spnd_l, convert: toInt, show: hasSpindle }),
ov_botz: UC.newInput(LANG.ou_zbot_s, { title: LANG.ou_zbot_l, convert: toFloat, units }), rate: UC.newInput(LANG.cc_feed_s, { title: LANG.cc_feed_l, convert: toInt, units }),
plunge: UC.newInput(LANG.cc_plng_s, { title: LANG.cc_plng_l, convert: toInt, units }),
exp_end: UC.endExpand(),
exp: UC.newExpand("top & bottom", ov_hover),
ov_topz: UC.newInput(LANG.ou_ztop_s, ov_topz),
ov_botz: UC.newInput(LANG.ou_zbot_s, ov_botz),
exp_end: UC.endExpand(), exp_end: UC.endExpand(),
}; };
@ -278,9 +372,6 @@ export function createPopOps() {
tool: UC.newSelect(LANG.cc_tool, {}, "tools"), tool: UC.newSelect(LANG.cc_tool, {}, "tools"),
direction: UC.newSelect(LANG.ou_dire_s, { title: LANG.ou_dire_l }, "direction"), direction: UC.newSelect(LANG.ou_dire_s, { title: LANG.ou_dire_l }, "direction"),
sep: UC.newBlank({ class: "pop-sep" }), sep: UC.newBlank({ class: "pop-sep" }),
spindle: UC.newInput(LANG.cc_spnd_s, { title: LANG.cc_spnd_l, convert: toInt, show: hasSpindle }),
rate: UC.newInput(LANG.cc_feed_s, { title: LANG.cc_feed_l, convert: toInt, units }),
plunge: UC.newInput(LANG.cc_plng_s, { title: LANG.cc_plng_l, convert: toInt, units }),
down: UC.newInput(LANG.cc_sdwn_s, { title: LANG.cc_sdwn_l, convert: toFloat, units }), down: UC.newInput(LANG.cc_sdwn_s, { title: LANG.cc_sdwn_l, convert: toFloat, units }),
step: UC.newInput(LANG.cc_sovr_s, { title: LANG.cc_sovr_l, convert: toFloat, bound: UC.bound(0.01, 1.0), show: () => env.popOp.outline.rec.wide }), step: UC.newInput(LANG.cc_sovr_s, { title: LANG.cc_sovr_l, convert: toFloat, bound: UC.bound(0.01, 1.0), show: () => env.popOp.outline.rec.wide }),
steps: UC.newInput(LANG.cc_sovc_s, { title: LANG.cc_sovc_l, convert: toInt, bound: UC.bound(1, 500), show: () => env.popOp.outline.rec.wide }), steps: UC.newInput(LANG.cc_sovc_s, { title: LANG.cc_sovc_l, convert: toInt, bound: UC.bound(1, 500), show: () => env.popOp.outline.rec.wide }),
@ -294,9 +385,14 @@ export function createPopOps() {
dogbones: UC.newBoolean(LANG.co_dogb_s, undefined, { title: LANG.co_dogb_l, show: (op) => { return !op.inputs.wide.checked } }), dogbones: UC.newBoolean(LANG.co_dogb_s, undefined, { title: LANG.co_dogb_l, show: (op) => { return !op.inputs.wide.checked } }),
revbones: UC.newBoolean(LANG.co_dogr_s, undefined, { title: LANG.co_dogr_l, show: () => env.poppedRec.dogbones }), revbones: UC.newBoolean(LANG.co_dogr_s, undefined, { title: LANG.co_dogr_l, show: () => env.poppedRec.dogbones }),
sep: UC.newBlank({ class: "pop-sep" }), sep: UC.newBlank({ class: "pop-sep" }),
exp: UC.newExpand("overrides"), exp: UC.newExpand("feeds & speeds", { }),
ov_topz: UC.newInput(LANG.ou_ztop_s, { title: LANG.ou_ztop_l, convert: toFloat, units }), spindle: UC.newInput(LANG.cc_spnd_s, { title: LANG.cc_spnd_l, convert: toInt, show: hasSpindle }),
ov_botz: UC.newInput(LANG.ou_zbot_s, { title: LANG.ou_zbot_l, convert: toFloat, units }), rate: UC.newInput(LANG.cc_feed_s, { title: LANG.cc_feed_l, convert: toInt, units }),
plunge: UC.newInput(LANG.cc_plng_s, { title: LANG.cc_plng_l, convert: toInt, units }),
exp_end: UC.endExpand(),
exp: UC.newExpand("top & bottom", ov_hover),
ov_topz: UC.newInput(LANG.ou_ztop_s, ov_topz),
ov_botz: UC.newInput(LANG.ou_zbot_s, ov_botz),
exp_end: UC.endExpand(), exp_end: UC.endExpand(),
}; };
@ -316,6 +412,7 @@ export function createPopOps() {
bottom: 'camContourBottom', bottom: 'camContourBottom',
curves: 'camContourCurves', curves: 'camContourCurves',
inside: 'camContourIn', inside: 'camContourIn',
clipto: 'camStockClipTo',
filter: 'camContourFilter', filter: 'camContourFilter',
axis: 'X' axis: 'X'
}).inputs = { }).inputs = {
@ -337,14 +434,17 @@ export function createPopOps() {
curves: UC.newBoolean(LANG.cf_curv_s, undefined, { title: LANG.cf_curv_l }), curves: UC.newBoolean(LANG.cf_curv_s, undefined, { title: LANG.cf_curv_l }),
inside: UC.newBoolean(LANG.cf_olin_s, undefined, { title: LANG.cf_olin_l }), inside: UC.newBoolean(LANG.cf_olin_s, undefined, { title: LANG.cf_olin_l }),
bottom: UC.newBoolean(LANG.cf_botm_s, undefined, { title: LANG.cf_botm_l, show: (op, conf) => conf ? conf.process.camZBottom : 0 }), bottom: UC.newBoolean(LANG.cf_botm_s, undefined, { title: LANG.cf_botm_l, show: (op, conf) => conf ? conf.process.camZBottom : 0 }),
clipto: UC.newBoolean(LANG.cf_clip_s, undefined, { title:LANG.cf_clip_l, show: () => !isWebGPU() }),
filter: UC.newRow([UC.newButton(LANG.filter, contourFilter)], { class: "ext-buttons f-row" }) filter: UC.newRow([UC.newButton(LANG.filter, contourFilter)], { class: "ext-buttons f-row" })
}; };
createPopOp('lathe', { createPopOp('lathe', {
tool: 'camLatheTool', tool: 'camLatheTool',
spindle: 'camLatheSpindle', spindle: 'camLatheSpindle',
step: 'camLatheOver',
angle: 'camLatheAngle', angle: 'camLatheAngle',
step: 'camLatheOver',
down: 'camLatheDown',
expand: 'camLatheExpand',
rate: 'camLatheSpeed', rate: 'camLatheSpeed',
tolerance: 'camTolerance', tolerance: 'camTolerance',
filter: 'camContourFilter', filter: 'camContourFilter',
@ -358,8 +458,11 @@ export function createPopOps() {
spindle: UC.newInput(LANG.cc_spnd_s, { title: LANG.cc_spnd_l, convert: toInt, show: hasSpindle }), spindle: UC.newInput(LANG.cc_spnd_s, { title: LANG.cc_spnd_l, convert: toInt, show: hasSpindle }),
rate: UC.newInput(LANG.cc_feed_s, { title: LANG.cc_feed_l, convert: toInt, units }), rate: UC.newInput(LANG.cc_feed_s, { title: LANG.cc_feed_l, convert: toInt, units }),
sep: UC.newBlank({ class: "pop-sep" }), sep: UC.newBlank({ class: "pop-sep" }),
step: UC.newInput(LANG.cc_sovr_s, { title: LANG.cc_sovr_l, convert: toFloat, bound: UC.bound(0.01, 100.0) }),
angle: UC.newInput(LANG.cc_sang_s, { title: LANG.cc_sang_l, convert: toFloat, bound: UC.bound(0.01, 180.0) }), angle: UC.newInput(LANG.cc_sang_s, { title: LANG.cc_sang_l, convert: toFloat, bound: UC.bound(0.01, 180.0) }),
step: UC.newInput(LANG.cc_sovr_s, { title: LANG.cc_sovr_l, convert: toFloat, bound: UC.bound(0.01, 100.0) }),
sepDn: UC.newBlank({ class: "pop-sep", show: () => env.poppedRec.down > 0 }),
down: UC.newInput(LANG.cc_sdwn_s, { title: LANG.cc_sdwn_l, convert: toFloat, units, show: isWebGPU }),
expand: UC.newInput(LANG.cl_xpnd_s, { title: LANG.cl_xpnd_l, convert: toFloat, units, show: () => env.poppedRec.down > 0 }),
sep: UC.newBlank({ class: "pop-sep" }), sep: UC.newBlank({ class: "pop-sep" }),
offStart: UC.newInput(LANG.ci_laso_s, { title: LANG.ci_laso_l, convert: toFloat}), offStart: UC.newInput(LANG.ci_laso_s, { title: LANG.ci_laso_l, convert: toFloat}),
offEnd: UC.newInput(LANG.ci_laeo_s, { title: LANG.ci_laeo_l, convert: toFloat}), offEnd: UC.newInput(LANG.ci_laeo_s, { title: LANG.ci_laeo_l, convert: toFloat}),
@ -370,19 +473,6 @@ export function createPopOps() {
linear: UC.newBoolean(LANG.ci_line_s, undefined, { title: LANG.ci_line_l }), linear: UC.newBoolean(LANG.ci_line_s, undefined, { title: LANG.ci_line_l }),
}; };
function canDogBones() {
if (!env.poppedRec) return false;
return env.poppedRec.mode === 'follow';
}
function canDogBonesRev() {
return canDogBones() && env.poppedRec.dogbone;
}
function zDogSep() {
return canDogBones() || zBottom();
}
createPopOp('trace', { createPopOp('trace', {
mode: 'camTraceType', mode: 'camTraceType',
offset: 'camTraceOffset', offset: 'camTraceOffset',
@ -407,10 +497,6 @@ export function createPopOps() {
offset: UC.newSelect(LANG.cc_offs_s, { title: LANG.cc_offs_l, show: () => (env.poppedRec.mode === 'follow') }, "traceoff"), offset: UC.newSelect(LANG.cc_offs_s, { title: LANG.cc_offs_l, show: () => (env.poppedRec.mode === 'follow') }, "traceoff"),
direction: UC.newSelect(LANG.ou_dire_s, { title: LANG.ou_dire_l }, "direction"), direction: UC.newSelect(LANG.ou_dire_s, { title: LANG.ou_dire_l }, "direction"),
sep: UC.newBlank({ class: "pop-sep" }), sep: UC.newBlank({ class: "pop-sep" }),
spindle: UC.newInput(LANG.cc_spnd_s, { title: LANG.cc_spnd_l, convert: toInt, show: hasSpindle }),
rate: UC.newInput(LANG.cc_feed_s, { title: LANG.cc_feed_l, convert: toInt, units }),
plunge: UC.newInput(LANG.cc_plng_s, { title: LANG.cc_plng_l, convert: toInt, units }),
sep: UC.newBlank({ class: "pop-sep" }),
step: UC.newInput(LANG.cc_sovr_s, { title: LANG.cc_sovr_l, convert: toFloat, bound: UC.bound(0.01, 1.0), show: (op) => env.popOp.trace.rec.mode === "clear" }), step: UC.newInput(LANG.cc_sovr_s, { title: LANG.cc_sovr_l, convert: toFloat, bound: UC.bound(0.01, 1.0), show: (op) => env.popOp.trace.rec.mode === "clear" }),
down: UC.newInput(LANG.cc_sdwn_s, { title: LANG.cc_sdwn_l, convert: toFloat, units }), down: UC.newInput(LANG.cc_sdwn_s, { title: LANG.cc_sdwn_l, convert: toFloat, units }),
offover: UC.newInput(LANG.cc_offd_s, { title: LANG.cc_offd_l, convert: toFloat, units, show: () => env.poppedRec.offset !== "none" || env.poppedRec.mode === "clear" }), offover: UC.newInput(LANG.cc_offd_s, { title: LANG.cc_offd_l, convert: toFloat, units, show: () => env.poppedRec.offset !== "none" || env.poppedRec.mode === "clear" }),
@ -419,13 +505,22 @@ export function createPopOps() {
merge: UC.newBoolean(LANG.co_merg_s, undefined, { title: LANG.co_merg_l, show: () => !env.popOp.trace.rec.down }), merge: UC.newBoolean(LANG.co_merg_s, undefined, { title: LANG.co_merg_l, show: () => !env.popOp.trace.rec.down }),
dogbone: UC.newBoolean(LANG.co_dogb_s, undefined, { title: LANG.co_dogb_l, show: canDogBones }), dogbone: UC.newBoolean(LANG.co_dogb_s, undefined, { title: LANG.co_dogb_l, show: canDogBones }),
revbone: UC.newBoolean(LANG.co_dogr_s, undefined, { title: LANG.co_dogr_l, show: canDogBonesRev }), revbone: UC.newBoolean(LANG.co_dogr_s, undefined, { title: LANG.co_dogr_l, show: canDogBonesRev }),
exp: UC.newExpand("overrides"), exp: UC.newExpand("feeds & speeds", { }),
spindle: UC.newInput(LANG.cc_spnd_s, { title: LANG.cc_spnd_l, convert: toInt, show: hasSpindle }),
rate: UC.newInput(LANG.cc_feed_s, { title: LANG.cc_feed_l, convert: toInt, units }),
plunge: UC.newInput(LANG.cc_plng_s, { title: LANG.cc_plng_l, convert: toInt, units }),
exp_end: UC.endExpand(),
exp: UC.newExpand("top & bottom", ov_hover),
sep: UC.newBlank({ class: "pop-sep" }), sep: UC.newBlank({ class: "pop-sep" }),
ov_topz: UC.newInput(LANG.ou_ztop_s, { title: LANG.ou_ztop_l, convert: toFloat, units }), ov_topz: UC.newInput(LANG.ou_ztop_s, ov_topz),
ov_botz: UC.newInput(LANG.ou_zbot_s, { title: LANG.ou_zbot_l, convert: toFloat, units }), ov_botz: UC.newInput(LANG.ou_zbot_s, ov_botz),
exp_end: UC.endExpand(), exp_end: UC.endExpand(),
sep: UC.newBlank({ class: "pop-sep" }), sep: UC.newBlank({ class: "pop-sep" }),
menu: UC.newRow([UC.newButton("select", traceAdd)], { class: "ext-buttons f-row" }), menu: UC.newRow([
UC.newButton("select", traceAdd),
// UC.newButton("load", traceLoad, { show: () => env.poppedRec.mode === "follow" }),
// UC.newButton("", traceClear, { icon: '<i class="fa fa-trash"></i>' }),
], { class: "ext-buttons f-row", row: true }),
}; };
createPopOp('pocket', { createPopOp('pocket', {
@ -450,10 +545,6 @@ export function createPopOps() {
tool: UC.newSelect(LANG.cc_tool, {}, "tools"), tool: UC.newSelect(LANG.cc_tool, {}, "tools"),
direction: UC.newSelect(LANG.ou_dire_s, { title: LANG.ou_dire_l }, "direction"), direction: UC.newSelect(LANG.ou_dire_s, { title: LANG.ou_dire_l }, "direction"),
sep: UC.newBlank({ class: "pop-sep" }), sep: UC.newBlank({ class: "pop-sep" }),
spindle: UC.newInput(LANG.cc_spnd_s, { title: LANG.cc_spnd_l, convert: toInt, show: hasSpindle }),
rate: UC.newInput(LANG.cc_feed_s, { title: LANG.cc_feed_l, convert: toInt, units }),
plunge: UC.newInput(LANG.cc_plng_s, { title: LANG.cc_plng_l, convert: toInt, units }),
sep: UC.newBlank({ class: "pop-sep" }),
step: UC.newInput(LANG.cc_sovr_s, { title: LANG.cc_sovr_l, convert: toFloat, bound: UC.bound(0.01, 1.0) }), step: UC.newInput(LANG.cc_sovr_s, { title: LANG.cc_sovr_l, convert: toFloat, bound: UC.bound(0.01, 1.0) }),
down: UC.newInput(LANG.cc_sdwn_s, { title: LANG.cc_sdwn_l, convert: toFloat, units, show: () => !env.poppedRec.contour }), down: UC.newInput(LANG.cc_sdwn_s, { title: LANG.cc_sdwn_l, convert: toFloat, units, show: () => !env.poppedRec.contour }),
sep: UC.newBlank({ class: "pop-sep" }), sep: UC.newBlank({ class: "pop-sep" }),
@ -465,10 +556,15 @@ export function createPopOps() {
sep: UC.newBlank({ class: "pop-sep" }), sep: UC.newBlank({ class: "pop-sep" }),
contour: UC.newBoolean(LANG.cp_cont_s, undefined, { title: LANG.cp_cont_s }), contour: UC.newBoolean(LANG.cp_cont_s, undefined, { title: LANG.cp_cont_s }),
outline: UC.newBoolean(LANG.cp_outl_s, undefined, { title: LANG.cp_outl_l }), outline: UC.newBoolean(LANG.cp_outl_s, undefined, { title: LANG.cp_outl_l }),
exp: UC.newExpand("overrides"), exp: UC.newExpand("feeds & speeds", { }),
spindle: UC.newInput(LANG.cc_spnd_s, { title: LANG.cc_spnd_l, convert: toInt, show: hasSpindle }),
rate: UC.newInput(LANG.cc_feed_s, { title: LANG.cc_feed_l, convert: toInt, units }),
plunge: UC.newInput(LANG.cc_plng_s, { title: LANG.cc_plng_l, convert: toInt, units }),
exp_end: UC.endExpand(),
exp: UC.newExpand("top & bottom", ov_hover),
sep: UC.newBlank({ class: "pop-sep" }), sep: UC.newBlank({ class: "pop-sep" }),
ov_topz: UC.newInput(LANG.ou_ztop_s, { title: LANG.ou_ztop_l, convert: toFloat, units }), ov_topz: UC.newInput(LANG.ou_ztop_s, ov_topz),
ov_botz: UC.newInput(LANG.ou_zbot_s, { title: LANG.ou_zbot_l, convert: toFloat, units }), ov_botz: UC.newInput(LANG.ou_zbot_s, ov_botz),
exp_end: UC.endExpand(), exp_end: UC.endExpand(),
sep: UC.newBlank({ class: "pop-sep" }), sep: UC.newBlank({ class: "pop-sep" }),
menu: UC.newRow([UC.newButton("select", surfaceAdd)], { class: "ext-buttons f-row" }), menu: UC.newRow([UC.newButton("select", surfaceAdd)], { class: "ext-buttons f-row" }),
@ -592,17 +688,13 @@ export function createPopOps() {
action: UC.newRow([UC.newButton(LANG.edit, gcodeEditor())], { class: "ext-buttons f-row" }) action: UC.newRow([UC.newButton(LANG.edit, gcodeEditor())], { class: "ext-buttons f-row" })
}; };
function angleTowardZUp() {
api.event.emit('tool.mesh.face-up');
}
createPopOp('index', { createPopOp('index', {
degrees: 'camIndexAxis', degrees: 'camIndexAxis',
absolute: 'camIndexAbs' absolute: 'camIndexAbs'
}).inputs = { }).inputs = {
degrees: UC.newInput(LANG.ci_degr_s, { title: LANG.ci_degr_l, convert: toFloat, bound: UC.bound(-360, 360.0) }), degrees: UC.newInput(LANG.ci_degr_s, { title: LANG.ci_degr_l, convert: toFloat, bound: UC.bound(-360, 360.0) }),
absolute: UC.newBoolean(LANG.ci_abso_s, undefined, { title: LANG.ci_abso_l }), absolute: UC.newBoolean(LANG.ci_abso_s, undefined, { title: LANG.ci_abso_l }),
select: UC.newRow([UC.newButton(LANG.ci_face_s, angleTowardZUp)], { class: "ext-buttons f-row" }) select: UC.newRow([UC.newButton(LANG.ci_face_s, startFaceUp)], { class: "ext-buttons f-row" })
}; };
const editEnable = gcodeEditor('Laser Enable Script', 'enable'); const editEnable = gcodeEditor('Laser Enable Script', 'enable');
@ -649,23 +741,13 @@ export function createPopOps() {
createPopOp('|', {}).inputs = {}; createPopOp('|', {}).inputs = {};
function isClear() { createPopOp('loop', {
return env.poppedRec.mode === 'clear'; repeat: 2,
} count: 1
}).inputs = {
function isTrace() { repeat: UC.newInput('repeat', { title: "repeat count", convert: toInt, bound: UC.bound(1, 100), post: opRender }),
return env.poppedRec.mode === 'trace'; count: UC.newInput('operations', { title: "num operations", convert: toInt, bound: UC.bound(1, 20), post: opRender })
} };
function isSurface() {
return env.poppedRec.mode === 'surface';
}
function isSurfaceLinear() {
return env.poppedRec.mode === 'surface' && env.poppedRec.sr_type === 'linear';
}
const open = true;
createPopOp('area', { createPopOp('area', {
spindle: 'camAreaSpindle', spindle: 'camAreaSpindle',
@ -675,15 +757,18 @@ export function createPopOps() {
tr_type: 'camAreaTrace', tr_type: 'camAreaTrace',
sr_type: 'camAreaSurface', sr_type: 'camAreaSurface',
sr_angle: 'camAreaAngle', sr_angle: 'camAreaAngle',
sr_alter: 'camAreaZigZag',
over: 'camAreaOver', over: 'camAreaOver',
down: 'camAreaDown', down: 'camAreaDown',
rate: 'camAreaSpeed', rate: 'camAreaSpeed',
plunge: 'camAreaPlunge', plunge: 'camAreaPlunge',
expand: 'camAreaExpand', expand: 'camAreaExpand',
walls: 'camAreaWalls',
smooth: 'camAreaSmooth', smooth: 'camAreaSmooth',
follow: 'camAreaFollow', follow: 'camAreaFollow',
refine: 'camAreaRefine', refine: 'camAreaRefine',
outline: 'camAreaOutline', outline: 'camAreaOutline',
shadow: 'camAreaShadow',
tolerance: 'camTolerance', tolerance: 'camTolerance',
dogbones: 'camAreaDogbones', dogbones: 'camAreaDogbones',
revbones: 'camAreaRevbones', revbones: 'camAreaRevbones',
@ -694,35 +779,43 @@ export function createPopOps() {
tr_type: UC.newSelect(LANG.cc_offs_s, { title: LANG.cc_offs_l, show: isTrace }, "traceoff"), tr_type: UC.newSelect(LANG.cc_offs_s, { title: LANG.cc_offs_l, show: isTrace }, "traceoff"),
sr_type: UC.newSelect("pattern", { title: "pattern", show: isSurface }, "surftyp"), sr_type: UC.newSelect("pattern", { title: "pattern", show: isSurface }, "surftyp"),
sep: UC.newBlank({ class: "pop-sep" }), sep: UC.newBlank({ class: "pop-sep" }),
exp: UC.newExpand("area selection", { open }),
menu: UC.newRow([ menu: UC.newRow([
UC.newButton("edge", traceAdd), UC.newButton("edge", traceAdd),
UC.newButton("surface", surfaceAdd), UC.newButton("surface", surfaceAdd),
], { class: "ext-buttons f-row" }), ], { class: "ext-buttons f-row", show: () => !isShadow() }),
shadow: UC.newBoolean(LANG.cp_shad_s, undefined, { title: LANG.cp_shad_l }),
outline: UC.newBoolean(LANG.cp_outl_s, undefined, { title: LANG.cp_outl_l }), outline: UC.newBoolean(LANG.cp_outl_s, undefined, { title: LANG.cp_outl_l }),
exp_end: UC.endExpand(),
sep: UC.newBlank({ class: "pop-sep" }),
exp: UC.newExpand("area modifiers", { }),
walls: UC.newBoolean(LANG.ca_wall_s, undefined, { title: LANG.ca_wall_l }),
expand: UC.newInput(LANG.cp_xpnd_s, { title: LANG.cp_xpnd_l, convert: toFloat, units }), expand: UC.newInput(LANG.cp_xpnd_s, { title: LANG.cp_xpnd_l, convert: toFloat, units }),
smooth: UC.newInput(LANG.cp_smoo_s, { title: LANG.cp_smoo_l, convert: toInt, xshow: isSurface }), smooth: UC.newInput(LANG.cp_smoo_s, { title: LANG.cp_smoo_l, convert: toInt, xshow: isSurface }),
follow: UC.newInput(LANG.cp_foll_s, { title: LANG.cp_foll_l, convert: toFloat }), follow: UC.newInput(LANG.cp_foll_s, { title: LANG.cp_foll_l, convert: toFloat }),
tolerance: UC.newInput(LANG.ou_toll_s, { title: LANG.ou_toll_l, convert: toFloat, bound: UC.bound(0, 10.0), units, round: 4, show: isSurface }), tolerance: UC.newInput(LANG.ou_toll_s, { title: LANG.ou_toll_l, convert: toFloat, bound: UC.bound(0, 10.0), units, round: 4, show: isSurface }),
exp_end: UC.endExpand(),
exp: UC.newExpand("tool & stepping", { open }), exp: UC.newExpand("tool & stepping", { open }),
tool: UC.newSelect(LANG.cc_tool, {}, "tools"), tool: UC.newSelect(LANG.cc_tool, {}, "tools"),
direction: UC.newSelect(LANG.ou_dire_s, { title: LANG.ou_dire_l }, "direction"), direction: UC.newSelect(LANG.ou_dire_s, { title: LANG.ou_dire_l }, "direction"),
sr_angle: UC.newInput("step angle", { title: "step angle", convert: toFloat, bound: UC.bound(0, 360), show: isSurfaceLinear }), sr_angle: UC.newInput(LANG.ca_sang_s, { title: LANG.ca_sang_l, convert: toFloat, bound: UC.bound(0, 360), show: isSurfaceLinear }),
over: UC.newInput(LANG.cc_sovr_s, { title: LANG.cc_sovr_l, convert: toFloat, bound: UC.bound(0.001, 100.0), show: () => isClear() || isSurface() }), over: UC.newInput(LANG.cc_sovr_s, { title: LANG.cc_sovr_l, convert: toFloat, bound: UC.bound(0.001, 100.0), show: () => isClear() || isSurface() }),
down: UC.newInput(LANG.cc_sdwn_s, { title: LANG.cc_sdwn_l, convert: toFloat, bound: UC.bound(0, 100.0), units, show: () => isClear() || isTrace() }), down: UC.newInput(LANG.cc_sdwn_s, { title: LANG.cc_sdwn_l, convert: toFloat, bound: UC.bound(0, 100.0), units, show: () => isClear() || isTrace() }),
refine: UC.newInput(LANG.cp_refi_s, { title: LANG.cp_refi_l, convert: toInt, show: isSurface }), refine: UC.newInput(LANG.cp_refi_s, { title: LANG.cp_refi_l, convert: toInt, show: isSurface }),
sr_alter: UC.newBoolean(LANG.ca_altr_s, undefined, { title: LANG.ca_altr_l, show: isSurfaceLinear }),
dogbones: UC.newBoolean(LANG.co_dogb_s, undefined, { title: LANG.co_dogb_l, show: isTrace }), dogbones: UC.newBoolean(LANG.co_dogb_s, undefined, { title: LANG.co_dogb_l, show: isTrace }),
revbones: UC.newBoolean(LANG.co_dogr_s, undefined, { title: LANG.co_dogr_l, show: () => env.poppedRec.dogbones }), revbones: UC.newBoolean(LANG.co_dogr_s, undefined, { title: LANG.co_dogr_l, show: () => env.poppedRec.dogbones }),
exp_end: UC.endExpand(), exp_end: UC.endExpand(),
exp: UC.newExpand("feeds & speeds", { open }), exp: UC.newExpand("feeds & speeds", { }),
sep: UC.newBlank({ class: "pop-sep" }), sep: UC.newBlank({ class: "pop-sep" }),
spindle: UC.newInput(LANG.cc_spnd_s, { title: LANG.cc_spnd_l, convert: toInt, show: hasSpindle }), spindle: UC.newInput(LANG.cc_spnd_s, { title: LANG.cc_spnd_l, convert: toInt, show: hasSpindle }),
rate: UC.newInput(LANG.cc_feed_s, { title: LANG.cc_feed_l, convert: toInt, units }), rate: UC.newInput(LANG.cc_feed_s, { title: LANG.cc_feed_l, convert: toInt, units }),
plunge: UC.newInput(LANG.cc_plng_s, { title: LANG.cc_plng_l, convert: toInt, units }), plunge: UC.newInput(LANG.cc_plng_s, { title: LANG.cc_plng_l, convert: toInt, units }),
exp_end: UC.endExpand(), exp_end: UC.endExpand(),
exp: UC.newExpand("bounds", { open }), exp: UC.newExpand("bounds", ov_hover),
sep: UC.newBlank({ class: "pop-sep" }), sep: UC.newBlank({ class: "pop-sep" }),
ov_topz: UC.newInput(LANG.ou_ztop_s, { title: LANG.ou_ztop_l, convert: toFloat, units }), ov_topz: UC.newInput(LANG.ou_ztop_s, ov_topz),
ov_botz: UC.newInput(LANG.ou_zbot_s, { title: LANG.ou_zbot_l, convert: toFloat, units }), ov_botz: UC.newInput(LANG.ou_zbot_s, ov_botz),
exp_end: UC.endExpand(), exp_end: UC.endExpand(),
}; };

View file

@ -1,13 +1,13 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */ /** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { api } from '../../core/api.js'; import { api } from '../../../app/api.js';
import { env, clearPops } from './client.js'; import { env, clearPops } from './init-ui.js';
import { CAM } from './driver-fe.js'; import { CAM } from './dispatch.js';
import { addbox, clearboxes } from '../../core/boxes.js'; import { addbox, clearboxes } from '../../../app/boxes.js';
export let originSelectOn = false; export let originSelectOn = false;
let { world } = api.SPACE; let { world } = api.const.SPACE;
let alert, lastHover, lastPoints; let alert, lastHover, lastPoints;
export function originSelect() { export function originSelect() {

View file

@ -1,13 +1,77 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */ /** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { api } from '../../core/api.js'; import { api } from '../../../app/api.js';
import { env, isDark } from './client.js'; import { env, isDark } from './init-ui.js';
import { space as SPACE } from '../../../moto/space.js'; import { space as SPACE } from '../../../../moto/space.js';
const { ui: UI } = api; const { ui: UI } = api;
let meshZTop, meshZBottom; let meshZTop, meshZBottom;
{
let geo = new THREE.PlaneGeometry(1, 1);
let mat = new THREE.MeshBasicMaterial({
color: 0x779977,
opacity: 0.55,
transparent: true,
side: THREE.DoubleSide
});
meshZTop = new THREE.Mesh(geo, mat);
meshZTop.renderOrder = 1;
}
{
let geo = new THREE.PlaneGeometry(1, 1);
let mat = new THREE.MeshBasicMaterial({
color: 0x997777,
opacity: 0.55,
transparent: true,
side: THREE.DoubleSide
});
meshZBottom = new THREE.Mesh(geo, mat);
meshZBottom.renderOrder = 1;
}
export function getBounds() {
const settings = api.conf.get();
const { stock } = settings;
const { x, y, z, center } = stock;
const widgets = api.widgets.all();
let max = { x, y, z };
for (let w of widgets) {
max.x = Math.max(max.x, w.track.box.w);
max.y = Math.max(max.y, w.track.box.h);
max.z = Math.max(max.z, w.track.box.d);
}
return widgets.length ? { max, center } : {};
}
export function showZTop(zval) {
SPACE.world.remove(meshZTop);
let { max, center } = getBounds();
if (zval && max && center) {
meshZTop.position.x = center.x;
meshZTop.position.y = center.y;
meshZTop.position.z = zval;
meshZTop.scale.x = max.x;
meshZTop.scale.y = max.y;
SPACE.world.add(meshZTop);
}
}
export function showZBottom(zval) {
SPACE.world.remove(meshZBottom);
let { max, center } = getBounds();
if (zval && max && center) {
meshZBottom.position.x = center.x;
meshZBottom.position.y = center.y;
meshZBottom.position.z = zval;
meshZBottom.scale.x = max.x;
meshZBottom.scale.y = max.y;
SPACE.world.add(meshZBottom);
}
}
export function updateStock() { export function updateStock() {
if (env.isAnimate) { if (env.isAnimate) {
if (env.isIndexed) { if (env.isIndexed) {
@ -22,16 +86,12 @@ export function updateStock() {
SPACE.world.remove(meshZBottom); SPACE.world.remove(meshZBottom);
SPACE.world.remove(env.camStock); SPACE.world.remove(env.camStock);
env.camStock = null; env.camStock = null;
meshZTop = null;
meshZBottom = null;
return; return;
} }
api.platform.update_bounds(); api.platform.update_bounds();
const settings = api.conf.get(); const settings = api.conf.get();
const widgets = api.widgets.all();
const { stock, process } = settings; const { stock, process } = settings;
const { x, y, z, center } = stock; const { x, y, z, center } = stock;
@ -86,57 +146,8 @@ export function updateStock() {
lines.material.color = new THREE.Color(isDark() ? 0x555555 : 0xaaaaaa); lines.material.color = new THREE.Color(isDark() ? 0x555555 : 0xaaaaaa);
} }
SPACE.world.remove(meshZTop); showZTop(process.camZTop);
if (process.camZTop && widgets.length) { showZBottom(process.camZBottom);
let max = { x, y, z };
for (let w of widgets) {
max.x = Math.max(max.x, w.track.box.w);
max.y = Math.max(max.y, w.track.box.h);
max.z = Math.max(max.z, w.track.box.d);
}
let geo = new THREE.PlaneGeometry(max.x, max.y);
let mat = new THREE.MeshBasicMaterial({
color: 0x777777,
opacity: 0.55,
transparent: true,
side: THREE.DoubleSide
});
meshZTop = new THREE.Mesh(geo, mat);
meshZTop._max = max;
meshZTop.renderOrder = 1;
meshZTop.position.x = center.x;
meshZTop.position.y = center.y;
meshZTop.position.z = process.camZTop;
SPACE.world.add(meshZTop);
} else {
meshZTop = undefined;
}
SPACE.world.remove(meshZBottom);
if (process.camZBottom && widgets.length) {
let max = { x, y, z };
for (let w of widgets) {
max.x = Math.max(max.x, w.track.box.w);
max.y = Math.max(max.y, w.track.box.h);
max.z = Math.max(max.z, w.track.box.d);
}
let geo = new THREE.PlaneGeometry(max.x, max.y);
let mat = new THREE.MeshBasicMaterial({
color: 0x777777,
opacity: 0.55,
transparent: true,
side: THREE.DoubleSide
});
meshZBottom = new THREE.Mesh(geo, mat);
meshZBottom._max = max;
meshZBottom.renderOrder = 1;
meshZBottom.position.x = center.x;
meshZBottom.position.y = center.y;
meshZBottom.position.z = process.camZBottom;
SPACE.world.add(meshZBottom);
} else {
meshZBottom = undefined;
}
SPACE.update(); SPACE.update();
} }

View file

@ -1,8 +1,8 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */ /** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { api } from '../../core/api.js'; import { api } from '../../../app/api.js';
import { env, clearPops } from './client.js'; import { env, clearPops } from './init-ui.js';
import { CAM } from './driver-fe.js'; import { CAM } from './dispatch.js';
const DEG2RAD = Math.PI / 180; const DEG2RAD = Math.PI / 180;
const RAD2DEG = 180 / Math.PI; const RAD2DEG = 180 / Math.PI;

View file

@ -1,10 +1,11 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */ /** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { api } from '../../core/api.js'; import { api } from '../../../app/api.js';
import { env, isDark } from './client.js'; import { colorSchemeRegistry } from '../../../app/color/schemes.js';
import { addbox, delbox, clearboxes } from '../../core/boxes.js'; import { env, isDark } from './init-ui.js';
import { addbox, delbox, clearboxes } from '../../../app/boxes.js';
import { traceDone } from './cl-trace.js'; import { traceDone } from './cl-trace.js';
import { space as SPACE } from '../../../moto/space.js'; import { space as SPACE } from '../../../../moto/space.js';
let showTab, lastTab, tab, iw, ic; let showTab, lastTab, tab, iw, ic;
@ -225,9 +226,15 @@ export function tabClear() {
} }
function boxColor() { function boxColor() {
return isDark() ? 0x00ddff : 0x0000dd; const mode = 3; // CAM mode ID
const theme = isDark() ? 'dark' : 'light';
const scheme = colorSchemeRegistry.getScheme(mode, theme);
return scheme.operations?.tabs?.color ?? (isDark() ? 0x00ddff : 0x0000dd);
} }
function boxOpacity() { function boxOpacity() {
return isDark() ? 0.75 : 0.6; const mode = 3; // CAM mode ID
const theme = isDark() ? 'dark' : 'light';
const scheme = colorSchemeRegistry.getScheme(mode, theme);
return scheme.operations?.tabs?.opacity ?? (isDark() ? 0.75 : 0.6);
} }

View file

@ -0,0 +1,230 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { api } from '../../../app/api.js';
import { env, clearPops, isDark } from './init-ui.js';
import { parse } from '../../../../load/svg.js';
import { CAM } from './dispatch.js';
import { Layers } from '../../../app/layers.js';
import { Stack } from '../../../app/stack.js';
import { newPolygon } from '../../../../geo/polygon.js';
export let traceOn = false;
export let lastTrace;
export function setLastTrace(trace) {
lastTrace = trace;
}
export function traceLoad(ev) {
let widget = api.widgets.all()[0];
if (!widget) return;
window.showOpenFilePicker({
types: [{
description: 'SVG Files',
accept: { 'text/svg': ['.svg'] }
}],
multiple: false
}).then(async fileHandle => {
const file = await fileHandle[0].getFile()
const text = await file.text(); // string
// const buf = await file.arrayBuffer(); // binary
// const blob = await file.slice();
const polys = parse(text, { flat: true, soup: true });
const rec = env.poppedRec;
rec.areas[widget.id] = polys.map(p => p.toArray());
rec.svg = true;
traceAdd();
}).catch(error => {
console.log('no file load', error);
});
}
export function traceClear(ev) {
return;
api.widgets.for(widget => {
unselectTraces(widget, true);
widget.traces = [];
widget.adds = [];
});
env.poppedRec.areas = [];
env.poppedRec.svg = false;
traceDone();
}
function startTraceAdd(ids) {
api.hide.alert(alert);
alert = api.show.alert("[esc] cancels trace editing");
api.widgets.for(widget => {
if (ids.indexOf(widget.id) >= 0) {
unselectTraces(widget, true);
widget.trace_stack = null;
}
if (widget.trace_stack) {
widget.adds.appendAll(widget.trace_stack.meshes);
widget.trace_stack.show();
return;
}
let areas = (env.poppedRec.areas[widget.id] || []);
let stack = new Stack(widget.mesh);
widget.trace_stack = stack;
widget.traces?.forEach(poly => {
let match = areas.filter(arr => poly.matches(arr));
let layers = new Layers();
layers.setLayer("trace", { line: 0xaaaa55, fat: 4, order: -10 }, false).addPoly(poly);
stack.addLayers(layers);
stack.new_meshes.forEach(mesh => {
mesh.trace = { widget, poly };
// ensure trace poly singleton from matches
if (match.length > 0) {
poly._trace = match[0];
} else {
poly._trace = poly.toArray();
}
});
widget.adds.appendAll(stack.new_meshes);
// console.log(widget)
});
});
// ensure appropriate traces are toggled matching current record
api.widgets.for(widget => {
widget.setVisualState({ opacity: 0.25 });
let areas = (env.poppedRec.areas[widget.id] || []);
let stack = widget.trace_stack;
stack.meshes.forEach(mesh => {
let { poly } = mesh.trace;
let match = areas.filter(arr => poly.matches(arr));
if (match.length > 0) {
if (!mesh.selected) {
traceToggle(mesh, true);
}
} else if (mesh.selected) {
traceToggle(mesh, true);
}
});
});
}
export function traceAdd(ev) {
if (traceOn) {
return traceDone();
}
clearPops();
alert = api.show.alert("analyzing parts...", 1000);
traceOn = env.hoveredOp;
traceOn.classList.add("editing");
api.feature.hover = true;
api.feature.hoverAdds = true;
env.hover = traceHover;
env.hoverUp = traceHoverUp;
if (env.poppedRec.svg === true) {
let widgets = api.widgets.all();
for (let [ id, areas ] of Object.entries(env.poppedRec.areas)) {
let widget = widgets.filter(w => w.id === id)[0];
widget.traces = areas.map(arr => newPolygon().fromArray(arr));
}
startTraceAdd(Object.keys(env.poppedRec.areas));
} else {
CAM.traces(startTraceAdd, env.poppedRec.select === 'lines');
}
}
export function traceDone() {
if (!traceOn) {
return;
}
env.func.unpop();
traceOn.classList.remove("editing");
traceOn = false;
api.widgets.setOpacity(1);
api.hide.alert(alert);
api.feature.hover = false;
api.feature.hoverAdds = false;
api.widgets.for(widget => {
widget.restoreVisualState();
if (widget.trace_stack) {
widget.trace_stack.hide();
widget.adds.removeAll(widget.trace_stack.meshes);
}
});
}
export function traceHover(data) {
if (lastTrace) {
let { color, colorSave } = lastTrace.material[0] || lastTrace.material;
color.r = colorSave.r;
color.g = colorSave.g;
color.b = colorSave.b;
}
lastTrace = null;
if (data.type === 'platform') {
return;
}
if (!data.int.object.trace) {
return;
}
lastTrace = data.int.object;
if (lastTrace.selected) {
let event = data.event;
let target = event.target;
let { clientX, clientY } = event;
let { offsetWidth, offsetHeight } = target;
}
let material = lastTrace.material[0] || lastTrace.material;
let color = material.color;
material.colorSave = color.clone();
color.setHex(isDark() ? 0x0066ff : 0x0000ff);
}
export function traceHoverUp(int, ev) {
if (!int) return;
let { object } = int;
traceToggle(object);
if (ev.metaKey || ev.ctrlKey) {
let { selected } = object;
let { widget, poly } = object.trace;
let avgZ = poly.avgZ();
for (let add of widget.adds) {
if (add.trace && add.selected !== selected && add.trace.poly.onZ(avgZ)) {
traceToggle(add);
}
}
}
}
export function traceToggle(obj, skip) {
let material = obj.material[0] || obj.material;
if (!(material && obj.trace)) return;
let { color, colorSave } = material;
let { widget, poly } = obj.trace;
let areas = env.poppedRec.areas;
if (!areas) {
return;
}
let wlist = areas[widget.id] = areas[widget.id] || [];
obj.selected = !obj.selected;
if (!colorSave) {
colorSave = material.colorSave = color.clone();
}
if (obj.selected) {
color.setHex(isDark() ? 0xdd0011 : 0xff0033);
colorSave.r = color.r;
colorSave.g = color.g;
colorSave.b = color.b;
if (!skip) wlist.push(poly._trace);
} else {
color.setHex(0xaaaa55);
colorSave.setHex(0xaaaa55);
if (!skip) wlist.remove(poly._trace);
}
api.conf.save();
}
export function unselectTraces(widget, skip) {
if (widget.trace_stack) {
widget.trace_stack.meshes.forEach(mesh => {
if (mesh.selected) {
traceToggle(mesh, skip);
}
});
}
}

View file

@ -0,0 +1,41 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { api } from '../../../app/api.js';
import { clearPops, hoverStart } from './init-ui.js';
import { showZTop, showZBottom, updateStock } from './cl-stock.js';
let alert;
let plane;
let ondone;
let lastY;
export function zPlaneStart(which, onselect) {
plane = which;
ondone = onselect;
hoverStart(onHover, onHoverUp);
alert = api.show.alert("[esc] cancels z plane selection", 1000);
}
export function zPlaneDone() {
api.hide.alert(alert);
updateStock();
alert = undefined;
}
export function zPlaneSelecting() {
return alert ? true : false;
}
function onHover(data) {
const { int, point } = data;
if (!int) return;
if (plane === 'top') showZTop(lastY = point.y); else
if (plane === 'bottom') showZBottom(lastY = point.y);
}
function onHoverUp(int) {
if (int) {
ondone(lastY);
clearPops();
}
}

View file

@ -1,7 +1,7 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */ /** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { api } from '../../core/api.js'; import { api } from '../../../app/api.js';
import { codec } from '../../core/codec.js'; import { codec } from '../../../core/codec.js';
function surface_prep(index, ondone) { function surface_prep(index, ondone) {
api.client.sync(); api.client.sync();

View file

@ -1,6 +1,6 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */ /** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { api } from '../../core/api.js'; import { api } from '../../../app/api.js';
import { originReset, originSelect } from './cl-origin.js'; import { originReset, originSelect } from './cl-origin.js';
import { updateTool } from './tools.js'; import { updateTool } from './tools.js';
@ -30,6 +30,30 @@ function zAnchorSave() {
api.platform.update_top_z(); api.platform.update_top_z();
} }
const bottom = true;
const top = true;
function selector(type, { input, event, opt }) {
let which = opt?.top ? 'top' : opt?.bottom ? 'bottom' : 'unknown';
let value = parseFloat(input.value);
switch (type) {
case 'click':
api.event.emit('cam.zplane.select', { which, onselect(value) {
input.focus();
input.value = value;
input.blur();
} } );
break;
case 'enter':
api.event.emit('cam.zplane.show', { which, value });
break;
case 'leave':
api.event.emit('cam.zplane.hide', { which });
break;
}
}
export function menu() { export function menu() {
let anim = ui.anim = {}; let anim = ui.anim = {};
@ -112,15 +136,17 @@ export function menu() {
camStockZ: newInput(LANG.cs_hght_s, {title:LANG.cs_hght_l, convert:toFloat, bound:bound(0,9999), units}), camStockZ: newInput(LANG.cs_hght_s, {title:LANG.cs_hght_l, convert:toFloat, bound:bound(0,9999), units}),
separator: newBlank({ class:"set-sep", driven }), separator: newBlank({ class:"set-sep", driven }),
camStockOffset: newBoolean(LANG.cs_offs_s, onBooleanClick, {title:LANG.cs_offs_l}), camStockOffset: newBoolean(LANG.cs_offs_s, onBooleanClick, {title:LANG.cs_offs_l}),
camStockClipTo: newBoolean(LANG.cs_clip_s, onBooleanClick, {title:LANG.cs_clip_l}),
camStockIndexed: newBoolean(LANG.cs_indx_s, onBooleanClick, {title:LANG.cs_indx_l}), camStockIndexed: newBoolean(LANG.cs_indx_s, onBooleanClick, {title:LANG.cs_indx_l}),
camStockIndexGrid: newBoolean(LANG.cs_ishg_s, onBooleanClick, {title:LANG.cs_ishg_l, show:() => ui.camStockIndexed.checked}), camStockIndexGrid: newBoolean(LANG.cs_ishg_s, onBooleanClick, {title:LANG.cs_ishg_l, show:() => ui.camStockIndexed.checked}),
// separator: newBlank({ class:"set-sep", driven }),
// camStockManual: newRow([
// (ui.stockPlace = newButton('position', onButtonClick, { })),
// ], {class:"ext-buttons f-row"}),
_____: newGroup(LANG.cc_menu, $('cam-limits'), { modes:CAM, driven, separator }), _____: newGroup(LANG.cc_menu, $('cam-limits'), { modes:CAM, driven, separator }),
camZAnchor: newSelect(LANG.ou_zanc_s, {title: LANG.ou_zanc_l, action:zAnchorSave, show:() => !ui.camStockIndexed.checked}, "zanchor"), camZAnchor: newSelect(LANG.ou_zanc_s, {title: LANG.ou_zanc_l, action:zAnchorSave, show:() => !ui.camStockIndexed.checked}, "zanchor"),
camZOffset: newInput(LANG.ou_ztof_s, {title:LANG.ou_ztof_l, convert:toFloat, units}), camZOffset: newInput(LANG.ou_ztof_s, {title:LANG.ou_ztof_l, convert:toFloat, units}),
camZTop: newInput(LANG.ou_ztop_s, {title:LANG.ou_ztop_l, convert:toFloat, units, trigger}), camZTop: newInput(LANG.ou_ztop_s, {title:LANG.ou_ztop_l, convert:toFloat, units, trigger, selector, top }),
camZBottom: newInput(LANG.ou_zbot_s, {title:LANG.ou_zbot_l, convert:toFloat, units, trigger}), camZBottom: newInput(LANG.ou_zbot_s, {title:LANG.ou_zbot_l, convert:toFloat, units, trigger, selector, bottom }),
// camZThru: newInput(LANG.ou_ztru_s, {title:LANG.ou_ztru_l, convert:toFloat, bound:bound(0.0,100), units }),
camZClearance: newInput(LANG.ou_zclr_s, {title:LANG.ou_zclr_l, convert:toFloat, bound:bound(0.01,100), units }), camZClearance: newInput(LANG.ou_zclr_s, {title:LANG.ou_zclr_l, convert:toFloat, bound:bound(0.01,100), units }),
separator: newBlank({ class:"set-sep", driven }), separator: newBlank({ class:"set-sep", driven }),
camFastFeed: newInput(LANG.cc_rapd_s, {title:LANG.cc_rapd_l, convert:toFloat, units}), camFastFeed: newInput(LANG.cc_rapd_s, {title:LANG.cc_rapd_l, convert:toFloat, units}),

View file

@ -1,24 +1,25 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */ /** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { $ } from '../../../moto/webui.js'; import { $ } from '../../../../moto/webui.js';
import { api } from '../../core/api.js'; import { api } from '../../../app/api.js';
import { load } from '../../../load/file.js'; import { animate as anim_2d, animate_clear as anim_2d_clear } from './anim-2d.js';
import { animate as anim_2d, animate_clear as anim_2d_clear } from './anim-2d-fe.js'; import { animate2 as anim_3d, animate_clear2 as anim_3d_clear } from './anim-3d.js';
import { animate2 as anim_3d, animate_clear2 as anim_3d_clear } from './anim-3d-fe.js';
import { space as SPACE } from '../../../moto/space.js';
import { Layers } from '../../core/layers.js';
import { Stack } from '../../core/stack.js';
import { updateStock } from './cl-stock.js';
import { createPopOps } from './cl-ops.js'; import { createPopOps } from './cl-ops.js';
import { helicalOn, helicalDone } from './cl-helical.js';
import { holeSelOn, selectHolesDone, clearHolesRec } from './cl-hole.js';
import { load } from '../../../../load/file.js';
import { Layers } from '../../../app/layers.js';
import { originSelectDone } from './cl-origin.js';
import { recreateTabs } from './cl-tab.js';
import { showZTop, showZBottom, updateStock } from './cl-stock.js';
import { space as SPACE } from '../../../../moto/space.js';
import { space } from '../../../../moto/space.js';
import { Stack } from '../../../app/stack.js';
import { surfaceOn, surfaceDone } from './cl-surface.js';
import { tabAdd, tabDone, tabClear, restoreTabs, rotateTabs, updateTabs, clearTabs, mirrorTabs } from './cl-tab.js'; import { tabAdd, tabDone, tabClear, restoreTabs, rotateTabs, updateTabs, clearTabs, mirrorTabs } from './cl-tab.js';
import { traceOn, traceDone, unselectTraces } from './cl-trace.js'; import { traceOn, traceDone, unselectTraces } from './cl-trace.js';
import { holeSelOn, selectHolesDone, clearHolesRec } from './cl-hole.js'; import { Widget, newWidget } from '../../../app/widget.js';
import { surfaceOn, surfaceDone } from './cl-surface.js'; import { zPlaneStart, zPlaneDone, zPlaneSelecting } from './cl-zplane.js';
import { helicalOn, helicalDone } from './cl-helical.js';
import { originSelectDone } from './cl-origin.js';
import { Widget, newWidget } from '../../core/widget.js';
import { space } from '../../../moto/space.js';
import { recreateTabs } from './cl-tab.js';
const { BufferGeometryUtils } = THREE; const { BufferGeometryUtils } = THREE;
@ -62,13 +63,28 @@ export function isDark() {
export function clearPops() { export function clearPops() {
if (env.func.unpop) env.func.unpop(); if (env.func.unpop) env.func.unpop();
helicalDone();
originSelectDone(); originSelectDone();
selectHolesDone(); selectHolesDone();
surfaceDone(); surfaceDone();
tabDone(); tabDone();
traceDone(); traceDone();
selectHolesDone(); zPlaneDone();
helicalDone(); // unbind
hoverDone();
}
export function hoverStart(onhover, onhoverup) {
clearPops();
env.hover = onhover;
env.hoverUp = onhoverup;
api.feature.hover = true;
}
export function hoverDone() {
env.hover = noop;
env.hoverUp = noop;
api.feature.hover = false;
} }
function animFn() { function animFn() {
@ -95,7 +111,8 @@ function updateIndex() {
if (op.absolute) { if (op.absolute) {
index = op.degrees index = op.degrees
} else { } else {
index += op.degrees; let numloop = op._numloop ?? 1;
index += (op.degrees * numloop);
} }
} }
} }
@ -124,6 +141,7 @@ function updateAxisMode(refresh) {
const showNonIndexed = env.isIndexed ? 'none' : ''; const showNonIndexed = env.isIndexed ? 'none' : '';
$('cam-index').style.display = showIndexed; $('cam-index').style.display = showIndexed;
$('cam-lathe').style.display = showIndexed; $('cam-lathe').style.display = showIndexed;
$('cam-loop').style.display = showIndexed;
$('cam-flip').style.display = showNonIndexed; $('cam-flip').style.display = showNonIndexed;
$('cam-reg').style.display = showNonIndexed; $('cam-reg').style.display = showNonIndexed;
if (!changed) { if (!changed) {
@ -165,6 +183,10 @@ const opAddLevel = () => {
opAdd(env.popOp.level.new()); opAdd(env.popOp.level.new());
}; };
const opAddLoop = () => {
opAdd(env.popOp.loop.new());
};
const opAddRough = () => { const opAddRough = () => {
opAdd(env.popOp.rough.new()); opAdd(env.popOp.rough.new());
}; };
@ -282,13 +304,29 @@ export function opRender() {
let mark = Date.now(); let mark = Date.now();
let html = []; let html = [];
let bind = {}; let bind = {};
let scale = api.view.unit_scale();
let notime = false; let notime = false;
let inloop = 0;
let numloop;
oplist.forEach((rec, i) => { oplist.forEach((rec, i) => {
let title = ''; let title = '';
let clock = rec.type === '|'; let clock = rec.type === '|';
let isLoop = rec.type === 'loop';
let label = clock ? `` : (rec.mode ? rec.type + ' ' + rec.mode : rec.type); let label = clock ? `` : (rec.mode ? rec.type + ' ' + rec.mode : rec.type);
let clazz = notime ? ["draggable", "notime"] : ["draggable"]; let clazz = notime ? ["draggable", "notime"] : ["draggable"];
// Loop operation label and styling
if (isLoop) {
label = `loop ${rec.repeat || 2}x`;
clazz.push('loop');
title = ` title="loop: repeat next ${rec.count || 1} operation(s) ${rec.repeat || 2} time(s)"`;
inloop = rec.count;
numloop = rec.repeat;
} else if (inloop && !clock) {
clazz.push('in-loop');
rec._numloop = numloop;
inloop--;
}
let notable = rec.note ? rec.note.split(' ').filter(v => v.charAt(0) === '#') : undefined; let notable = rec.note ? rec.note.split(' ').filter(v => v.charAt(0) === '#') : undefined;
if (clock) { clazz.push('clock'); title = ` title="end of ops chain\ndrag/drop like an op\nops after this are disabled"` } if (clock) { clazz.push('clock'); title = ` title="end of ops chain\ndrag/drop like an op\nops after this are disabled"` }
if (notable?.length) { if (notable?.length) {
@ -333,6 +371,9 @@ export function opRender() {
if (!env.isIndexed && type === 'lathe') { if (!env.isIndexed && type === 'lathe') {
rec.disabled = true; rec.disabled = true;
} }
if (!env.isIndexed && (type === 'loop' || rec._numloop)) {
rec.disabled = true;
}
if (!hasSharedArrays && (type === 'contour' || type === 'lathe')) { if (!hasSharedArrays && (type === 'contour' || type === 'lathe')) {
rec.disabled = true; rec.disabled = true;
} }
@ -346,7 +387,8 @@ export function opRender() {
let popped = false; let popped = false;
let poprec = env.popOp[rec.type]; let poprec = env.popOp[rec.type];
if (type === 'index' && indexing && !rec.disabled) { if (type === 'index' && indexing && !rec.disabled) {
index = rec.absolute ? rec.degrees : index + rec.degrees; let numloop = rec._numloop ?? 1;
index = rec.absolute ? rec.degrees : index + (rec.degrees * numloop);
} }
el.rec = rec; el.rec = rec;
el.unpop = () => { el.unpop = () => {
@ -529,10 +571,29 @@ export function opRender() {
updateStock(); updateStock();
} }
export function zPlaneShow({ which, value }) {
if (!value) return;
if (which === 'top') showZTop(value); else
if (which === 'bottom') showZBottom(value);
}
export function zPlaneHide({ which }) {
updateStock();
}
export function zPlaneSelect({ which, onselect }) {
if (zPlaneSelecting()) {
return zPlaneDone();
}
clearPops();
zPlaneStart(which, value => {
onselect(parseFloat(value));
});
}
export function init() { export function init() {
api.event.on('tool.mesh.face-normal', normal => { api.event.on('tool.mesh.face-normal', normal => {
// console.log({ env.poppedRec });
env.poppedRec.degrees = (Math.atan2(normal.y, normal.z) * RAD2DEG).round(2); env.poppedRec.degrees = (Math.atan2(normal.y, normal.z) * RAD2DEG).round(2);
env.poppedRec.absolute = true; env.poppedRec.absolute = true;
opRender(); opRender();
@ -747,14 +808,13 @@ export function init() {
}; };
$('op-add-list').onclick = (ev) => { $('op-add-list').onclick = (ev) => {
let settings = api.conf.get();
let { process, device } = settings;
switch (ev.target.innerText.toLowerCase()) { switch (ev.target.innerText.toLowerCase()) {
case "index": return opAddIndex(); case "index": return opAddIndex();
case "laser on": return opAddLaserOn(); case "laser on": return opAddLaserOn();
case "laser off": return opAddLaserOff(); case "laser off": return opAddLaserOff();
case "gcode": return opAddGCode(); case "gcode": return opAddGCode();
case "level": return opAddLevel(); case "level": return opAddLevel();
case "loop": return opAddLoop();
case "rough": return opAddRough(); case "rough": return opAddRough();
case "outline": return opAddOutline(); case "outline": return opAddOutline();
case "contour": case "contour":
@ -792,8 +852,24 @@ export function init() {
// TAB/TRACE BUTTON HANDLERS // TAB/TRACE BUTTON HANDLERS
api.event.on("button.click", target => { api.event.on("button.click", target => {
let process = api.conf.get().process; let { process, stock } = api.conf.get();
switch (target) { switch (target) {
case api.ui.stockPlace:
api.uc.prompt("stock x,y,z position", "").then(csv => {
if (csv) {
let [x,y,z] = csv.split(',').map(v => parseInt(v) || 0);
let center = {
x: x ?? 0,
y: y ?? 0,
z: z ?? 0
}
// move widgets based on postion
// more complex than just moving stock
// because stock is based on contents, not the other way
console.log({ x,y,z });
}
});
return;
case api.ui.tabAdd: case api.ui.tabAdd:
return tabAdd(); return tabAdd();
case api.ui.tabDun: case api.ui.tabDun:
@ -806,17 +882,20 @@ export function init() {
} }
}); });
api.event.on("cam.op.add", opAdd); api.event
.on("cam.op.add", opAdd)
.on("cam.op.del", opDel)
.on("cam.op.render", opRender);
api.event.on("cam.op.del", opDel); api.event
.on("cam.tabs.add", tabAdd)
.on("cam.tabs.done", tabDone)
.on("cam.tabs.clear", tabClear);
api.event.on("cam.op.render", opRender); api.event
.on("cam.zplane.show", zPlaneShow)
api.event.on("cam.tabs.add", tabAdd); .on("cam.zplane.hide", zPlaneHide)
.on("cam.zplane.select", zPlaneSelect);
api.event.on("cam.tabs.done", tabDone);
api.event.on("cam.tabs.clear", tabClear);
// COMMON TAB/TRACE EVENT HANDLERS // COMMON TAB/TRACE EVENT HANDLERS
api.event.on("slice.begin", () => { api.event.on("slice.begin", () => {

Some files were not shown because too many files have changed in this diff Show more