Merge branch 'rel-4.6'
This commit is contained in:
commit
c6c9846b96
184 changed files with 15553 additions and 9433 deletions
10
.gitignore
vendored
10
.gitignore
vendored
|
|
@ -1,5 +1,7 @@
|
|||
*.pem
|
||||
.DS_Store
|
||||
.bache
|
||||
.bcache
|
||||
.claude
|
||||
.docusaurus
|
||||
.env
|
||||
alt
|
||||
|
|
@ -7,12 +9,14 @@ app.json
|
|||
build
|
||||
data
|
||||
dist
|
||||
jszip-esm.js
|
||||
logs
|
||||
mod
|
||||
node_modules
|
||||
pack
|
||||
package-lock.json
|
||||
three.js
|
||||
src/ext/jszip-esm.js
|
||||
src/ext/quickjs.js
|
||||
src/ext/three.js
|
||||
tmp
|
||||
tmp_*/
|
||||
web/boot/bundle*
|
||||
|
|
|
|||
11
app.js
11
app.js
|
|
@ -579,16 +579,11 @@ function cookieValue(cookie,key) {
|
|||
}
|
||||
|
||||
function addAppHeaders(req, res, next) {
|
||||
if ([
|
||||
if (req.app.path.indexOf("/lib") === 0) {
|
||||
addCorsHeaders(req, res);
|
||||
} else if ([
|
||||
"/kiri/",
|
||||
"/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) {
|
||||
addCorsHeaders(req, res);
|
||||
}
|
||||
|
|
|
|||
28
bin/webpack-quickjs-bundle.js
Normal file
28
bin/webpack-quickjs-bundle.js
Normal 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;
|
||||
50
bin/webpack-quickjs-esm.js
Normal file
50
bin/webpack-quickjs-esm.js
Normal 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,
|
||||
},
|
||||
};
|
||||
|
|
@ -4,58 +4,119 @@ description: Keyboard Shortcuts and Mouse Controls
|
|||
|
||||
# Controls
|
||||
|
||||
| Key | Action | Notes |
|
||||
| ---------------- | ----------------------------------------- | --------------------------------------- |
|
||||
| e | Device Dialog | Select and customize devices |
|
||||
| o | CNC Tool Dialog | CNC mode only |
|
||||
| c | Grid.Local Dialog | Find GridBot devices on your network |
|
||||
| q | Preference Dialog | Change application behaviors |
|
||||
| l | Work Profile Dialog | Load, Save, Rename work profiles |
|
||||
| | | |
|
||||
| a | Arrange | Switch to layout view from other views |
|
||||
| | | or perform an automatic layout when |
|
||||
| | | already in layout view |
|
||||
| s | Slice Object | Slice workspace objects |
|
||||
| p | Preview Paths | Do route planning for sliced objects |
|
||||
| g | CNC Animation | Enter milling animation mode |
|
||||
| x | Export Paths as GCode, SVG, DXF | Produce downloadable files |
|
||||
| | | |
|
||||
| i | File Import Dialog | |
|
||||
| r | Recent Files Dialog | |
|
||||
| | | |
|
||||
| h | Home View | Default 45 degree view of objects |
|
||||
| t | Top View | Top-down view of objects |
|
||||
| f | Front View | Direct side-view (front) |
|
||||
| z | Reset View | Default zoom and Home |
|
||||
| < > | Cycle Side Views | Front, Right, Back, Left |
|
||||
| v | Focus on Selection (arrange mode) | Camera points to object center |
|
||||
| v | Toggle Single Slice View (preview mode) | Toggle single vs range of slices |
|
||||
| | | |
|
||||
| \~,1-9,0 | Select layer in 10% increments | Slice and Preview modes |
|
||||
| | | |
|
||||
| d | Duplicate Selected Objects | Create a copy of selected objects |
|
||||
| m | Mirror Selected Objects | Mirror vertices of selected objects |
|
||||
| \[delete] | Delete Selected Objects | Remove selected object from workspace |
|
||||
| \[arrow] | Rotate 90 degrees | Rotate selected object |
|
||||
| \[shift + arrow] | Rotate 5 degrees | Rotate selected object |
|
||||
| \[ctrl + a] | Select All Objects | Select all objects |
|
||||
| | | |
|
||||
| \[shit + click] | Toggle Selection | Select or Deselect a single object |
|
||||
| \[shift + wheel] | Cycle through layers (preview mode) | Use mouse wheel as range slider |
|
||||
| \[meta + drag] | Pan View | Move focus of camera |
|
||||
| \[right + drag] | Pan View | |
|
||||
| \[left + drag] | Rotate | Move camera around a focal point |
|
||||
| \[mouse wheel] | Zoom | Move camera closer / farther from focus |
|
||||
| \[middle + drag] | Zoom | |
|
||||
| | | |
|
||||
| \[ctrl + click] | Align face of selected object to platform | Rotate object face toward work surface |
|
||||
| \[meta + click] | Align face of selected object to platform | |
|
||||
| | | |
|
||||
| \[shift + z] | Reset all preferences to stock | Reload page afterward |
|
||||
| | | |
|
||||
| **Mouse** | **FDM Support Controls** | **Version Notes** |
|
||||
| \[left click] | Toggle add / remove column | |
|
||||
| \[ctrl + drag] | Erase support columns | 2.7+ |
|
||||
| \[cmd + drag] | Erase support columns | 2.7+ |
|
||||
| \[opt + drag] | Draw on support columns | 2.7+ |
|
||||
| \[alt + drag] | Draw on support columns | 2.7+ |
|
||||
## Layer Navigation
|
||||
|
||||
| Key | Action | Notes |
|
||||
| --- | ------ | ----- |
|
||||
| ` (backtick) | Show layer 0 | First layer |
|
||||
| 1-9 | Show layers at 10-90% | In 10% increments |
|
||||
| 0 | Show all layers (100%) | Show maximum layer |
|
||||
| Meta+Up | Next layer | Navigate up one layer |
|
||||
| Meta+Down | Previous layer | Navigate down one layer |
|
||||
| [shift + wheel] | Cycle through layers | Preview mode only |
|
||||
| v | Toggle single slice view | Preview mode: toggle single vs range |
|
||||
|
||||
## View Navigation
|
||||
|
||||
| Key | Action | Notes |
|
||||
| --- | ------ | ----- |
|
||||
| h | Home View | Default 45° view |
|
||||
| t | Top View | Top-down view |
|
||||
| f | Front View | Direct front side-view |
|
||||
| b | Back View | Direct back side-view |
|
||||
| z | Reset View | Default zoom and home |
|
||||
| F | Fit View to Contents | Zoom to fit all objects |
|
||||
| `<` | Previous Side View | Cycle: left → back → right → front |
|
||||
| `>` | Next Side View | Cycle: front → right → back → left |
|
||||
| v | Focus on Selection | Arrange mode: center camera on object |
|
||||
|
||||
## File Operations
|
||||
|
||||
| Key | Action | Notes |
|
||||
| --- | ------ | ----- |
|
||||
| i | File Import Dialog | Import 3D models |
|
||||
| r | Recent Files Dialog | Open recently used files |
|
||||
|
||||
## Workflow Actions
|
||||
|
||||
| Key | Action | Notes |
|
||||
| --- | ------ | ----- |
|
||||
| s / S | Slice Object | Process workspace objects |
|
||||
| p / P | Preview Paths | Route planning (hidden in SLA mode) |
|
||||
| g | CNC Animation | Enter milling animation mode (CNC only) |
|
||||
| x / X | Export | Export as GCode, SVG, or DXF |
|
||||
| a | Arrange | Switch to ARRANGE view, or auto-layout if already in ARRANGE |
|
||||
|
||||
## Object Manipulation
|
||||
|
||||
| Key | Action | Notes |
|
||||
| --- | ------ | ----- |
|
||||
| d | Duplicate Selected | Create copy of selected objects |
|
||||
| m | Mirror Selected | Mirror vertices of selected objects |
|
||||
| O | Manual Rotation Input | Enter rotation values manually |
|
||||
| [delete] | Delete Selected | Remove selected objects |
|
||||
| [ctrl + a] / [cmd + a] | Select All | Select all objects in workspace |
|
||||
| [shift + click] | Toggle Selection | Select or deselect individual object |
|
||||
|
||||
## 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 |
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
self.kiri.load(api => {
|
||||
|
||||
console.log('BAMBU MODULE RUNNING');
|
||||
|
||||
const { uc: ui } = api;
|
||||
const { $, h } = api.web;
|
||||
const defams = ";; DEFINE BAMBU-AMS ";
|
||||
|
|
@ -1232,7 +1230,7 @@ self.kiri.load(api => {
|
|||
if (!bound) {
|
||||
return;
|
||||
}
|
||||
if (devsel.extras?.bbl && !api.ui.deviceSave.disabled) {
|
||||
if (devsel.extras?.bbl && !api.ui.devices.save.disabled) {
|
||||
device = devsel;
|
||||
printers = devsel.extras.bbl;
|
||||
bound.bblman.classList.remove('hide');
|
||||
|
|
@ -1255,7 +1253,7 @@ self.kiri.load(api => {
|
|||
}
|
||||
|
||||
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';
|
||||
return;
|
||||
}
|
||||
|
|
@ -1327,4 +1325,4 @@ self.kiri.load(api => {
|
|||
setInterval(monitor_keepalive, 5000);
|
||||
|
||||
let bblapi = api.bambu = { send, prep_export };
|
||||
});
|
||||
}, 'Bambu');
|
||||
|
|
|
|||
|
|
@ -20,4 +20,4 @@ self.kiri.load(api => {
|
|||
}
|
||||
};
|
||||
});
|
||||
});
|
||||
}, 'Developer');
|
||||
|
|
|
|||
16
package.json
16
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "grid-apps",
|
||||
"version": "4.5.0",
|
||||
"version": "4.6.0",
|
||||
"description": "grid.space 3d slicing & modeling tools",
|
||||
"author": "Stewart Allen <sa@grid.space>",
|
||||
"license": "MIT",
|
||||
|
|
@ -35,12 +35,12 @@
|
|||
"node": ">=22.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/plugin-client-redirects": "^3.9.0",
|
||||
"@docusaurus/plugin-client-redirects": "3.9.1",
|
||||
"@fortawesome/fontawesome-free": "^6.1.1",
|
||||
"@gridspace/app-server": "^0.0.20",
|
||||
"@gridspace/basic-ftp": "github:gridspace/basic-ftp#v5.0.5-gridspace",
|
||||
"@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",
|
||||
"@tweenjs/tween.js": "^16.6.0",
|
||||
"aedes": "^0.51.3",
|
||||
|
|
@ -60,6 +60,7 @@
|
|||
"mqtt": "^5.10.3",
|
||||
"npm": "^11.4.2",
|
||||
"prettier": "^3.5.3",
|
||||
"quickjs-emscripten": "^0.20.0",
|
||||
"react-responsive-carousel": "^3.2.23",
|
||||
"serve-static": "^2.2.0",
|
||||
"three": "^0.182.0",
|
||||
|
|
@ -69,8 +70,8 @@
|
|||
"ws": "^8.18.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/core": "^3.9.0",
|
||||
"@docusaurus/preset-classic": "^3.9.0",
|
||||
"@docusaurus/core": "3.9.1",
|
||||
"@docusaurus/preset-classic": "3.9.1",
|
||||
"@electron/notarize": "^3.0.0",
|
||||
"copy-webpack-plugin": "^12.0.2",
|
||||
"cross-env": "^10.1.0",
|
||||
|
|
@ -104,7 +105,7 @@
|
|||
"docs-build": "docusaurus build --config conf/docusaurus.config.js",
|
||||
"docs-check": "prettier --config ./conf/prettier.config.js ./docs --check",
|
||||
"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-prod": "gs-app-server --single --dryrun",
|
||||
"mac-verify": "spctl --assess -vv --type install dist/*/*.app",
|
||||
|
|
@ -120,7 +121,8 @@
|
|||
"start-ddb": "npm run prebuild && electron . --devel --debugg",
|
||||
"start-dev": "npm run prebuild prod && electron . --devel",
|
||||
"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-three": "npx webpack --config bin/webpack-three-esm.js",
|
||||
"webpack-zip": "npx webpack --config bin/webpack-jszip-esm.js"
|
||||
|
|
|
|||
|
|
@ -676,8 +676,8 @@ export const config = {
|
|||
// default # of decimal places in generated gcode
|
||||
gcode_decimals: 4,
|
||||
// heal disjoint polygons in slicing (experimental)
|
||||
bridgeLineGapDistance: 0.05,
|
||||
bridgeLineGapDistanceMax: 25,
|
||||
bridgeLineGapDistance: 0.05, // may no longer be used
|
||||
bridgeLineGapDistanceMax: 3,
|
||||
// Bounds default margin nearTo
|
||||
// Polygon.offset mindist2 offset precision
|
||||
precision_offset: 0.05,
|
||||
|
|
|
|||
101
src/geo/paths.js
101
src/geo/paths.js
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { base } from './base.js';
|
||||
import { newPoint } from './point.js';
|
||||
import { newPolygon } from './polygon.js';
|
||||
|
||||
/**
|
||||
* Emit each element in array based on next closest endpoint
|
||||
|
|
@ -18,23 +19,26 @@ export function tip2tipEmit(array, startPoint, emitter) {
|
|||
for (;;) {
|
||||
found = null;
|
||||
mindist = Infinity;
|
||||
array.forEach(function (el) {
|
||||
if (el.delete) return;
|
||||
for (let el of array) {
|
||||
if (el.delete) continue;
|
||||
dist = startPoint.distTo2D(el.first);
|
||||
if (dist < mindist) {
|
||||
found = { el: el, first: el.first, last: el.last };
|
||||
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);
|
||||
if (dist < mindist) {
|
||||
found = { el: el, first: el.last, last: el.first };
|
||||
mindist = dist;
|
||||
}
|
||||
});
|
||||
}
|
||||
if (found) {
|
||||
found.el.delete = true;
|
||||
startPoint = found.last;
|
||||
emitter(found.el, found.first, ++count);
|
||||
emitter(found.el, found.first, ++count, mindist);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
|
|
@ -42,6 +46,38 @@ export function tip2tipEmit(array, startPoint, emitter) {
|
|||
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
|
||||
* 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = {
|
||||
tip2tipEmit,
|
||||
poly2polyEmit,
|
||||
shapeToPath,
|
||||
pointsToPath,
|
||||
pathTo3D,
|
||||
FloatPacker
|
||||
pathTo3D
|
||||
}
|
||||
|
|
|
|||
|
|
@ -877,6 +877,7 @@ class Point {
|
|||
const { x, y } = this;
|
||||
this.x = x * Math.cos(angle) - y * Math.sin(angle);
|
||||
this.y = y * Math.cos(angle) + x * Math.sin(angle);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
326
src/geo/poly/arcs.js
Normal file
326
src/geo/poly/arcs.js
Normal 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
280
src/geo/poly/geo.js
Normal 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
275
src/geo/poly/mesh.js
Normal 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
374
src/geo/poly/path.js
Normal 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;
|
||||
}
|
||||
1024
src/geo/polygon.js
1024
src/geo/polygon.js
File diff suppressed because it is too large
Load diff
|
|
@ -61,6 +61,7 @@ const POLYS = {
|
|||
offset,
|
||||
outer,
|
||||
points,
|
||||
print,
|
||||
rayIntersect,
|
||||
reconnect,
|
||||
renest,
|
||||
|
|
@ -71,6 +72,7 @@ const POLYS = {
|
|||
toClipper,
|
||||
trimTo,
|
||||
union,
|
||||
unionFaces,
|
||||
xor,
|
||||
};
|
||||
|
||||
|
|
@ -211,6 +213,15 @@ export function renest(polygons, 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?
|
||||
*
|
||||
|
|
@ -480,13 +491,89 @@ export function union(polys, minarea, all, opt = {}) {
|
|||
}
|
||||
|
||||
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;
|
||||
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
|
||||
* @returns {?Polygon[]}
|
||||
|
|
|
|||
|
|
@ -380,7 +380,7 @@ export async function sliceZ(z, points, options = {}) {
|
|||
|
||||
let rval = { z, lines };
|
||||
if (groupFn) {
|
||||
let groups = groupFn(lines, z, options);
|
||||
let groups = groupFn(lines, options);
|
||||
if (options.xor) {
|
||||
groups = polygons.xor(groups);
|
||||
}
|
||||
|
|
@ -448,12 +448,12 @@ export async function sliceZ(z, points, options = {}) {
|
|||
* interesection. Eliminate used points and repeat. Unjoined lines
|
||||
* are permitted and handled after all other cases are handled.
|
||||
*
|
||||
* @param {Line[]} input
|
||||
* @param {number} [index]
|
||||
* @param {Line[]} input array of lines (soup)
|
||||
* @param {Object} opt options
|
||||
* @returns {Array}
|
||||
*/
|
||||
export function sliceConnect(input, z, opt = {}) {
|
||||
let { debug, both } = opt;
|
||||
export function sliceConnect(input, opt = {}) {
|
||||
let { both, debug, dirty, strict } = opt;
|
||||
|
||||
if (both) {
|
||||
if (debug) console.log('unable to connect lines sliced with "both" option');
|
||||
|
|
@ -466,8 +466,8 @@ export function sliceConnect(input, z, opt = {}) {
|
|||
output = [],
|
||||
connect = [],
|
||||
emitted = 0,
|
||||
forks = false,
|
||||
frays = false,
|
||||
forks = [], // points which contain forks
|
||||
stubs = [], // dead end points
|
||||
bridge = config.bridgeLineGapDistance,
|
||||
bridgeMax = config.bridgeLineGapDistanceMax,
|
||||
p1, p2, gl;
|
||||
|
|
@ -480,11 +480,17 @@ export function sliceConnect(input, z, opt = {}) {
|
|||
return p;
|
||||
}
|
||||
|
||||
// add p2 to p1's group array
|
||||
function addConnected(p1, p2) {
|
||||
if (p1 === p2) {
|
||||
// console.log('drop same point connect');
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
if (!array.perimeter) {
|
||||
array.perimeter = newPolygon().addPoints(array).perimeter();
|
||||
|
|
@ -492,142 +498,230 @@ export function sliceConnect(input, z, opt = {}) {
|
|||
return array.perimeter;
|
||||
}
|
||||
|
||||
/**
|
||||
* follow points through connected lines to form candidate output paths
|
||||
*/
|
||||
function findNextPath(point, current, branches, depth = 1) {
|
||||
let path = [];
|
||||
if (current) {
|
||||
current.push(path);
|
||||
// calculate and store array area
|
||||
function area(array) {
|
||||
if (!array.area) {
|
||||
array.area = newPolygon().addPoints(array).area();
|
||||
}
|
||||
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 (;;) {
|
||||
// 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;
|
||||
|
||||
// add point to path
|
||||
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);
|
||||
|
||||
// no need to recurse at the start
|
||||
if (links.length === 2 && depth === 1) {
|
||||
point = links[0];
|
||||
// if (debug) console.log({start_mid: point, depth});
|
||||
continue;
|
||||
}
|
||||
if (debug) console.log({ point, depth, path, links: links.length });
|
||||
|
||||
// if fork in the road, follow all paths to their end
|
||||
// and find the longest path
|
||||
let root = !current, nc;
|
||||
if (links.length > 1) {
|
||||
// if (debug) console.log('fork!', {links: links.length, depth, root});
|
||||
if (root) {
|
||||
current = [ path ];
|
||||
branches = [ ];
|
||||
}
|
||||
if (links.length === 0) {
|
||||
// for dangling paths, terminate at any junction
|
||||
if (debug) console.log('term at stub or no branch')
|
||||
break;
|
||||
} else if (links.length === 1) {
|
||||
// one choice is easy, keep going
|
||||
point = links[0];
|
||||
} 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)
|
||||
for (let p of links) {
|
||||
branches.push(nc = current.slice());
|
||||
let rpath = findNextPath(p, nc, branches, depth + 1);
|
||||
// allow point re-use in other path searches
|
||||
for (let p of rpath) p.del = false;
|
||||
findNextPath(p, {
|
||||
base,
|
||||
branches,
|
||||
depth: depth + 1,
|
||||
marked,
|
||||
path: path.slice()
|
||||
});
|
||||
}
|
||||
// flatten and sort in ascending perimeter
|
||||
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;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
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;
|
||||
if (!opt.dirty) poly = poly.clean();
|
||||
if (poly.length > 2 || true) output.push(poly);
|
||||
if (debug) console.log('xray',poly);
|
||||
if (!dirty) poly = poly.clean();
|
||||
if (poly.length > 2) output.push(poly);
|
||||
if (debug) console.log('xray', poly);
|
||||
}
|
||||
|
||||
// given an array of paths, emit longest to shortest
|
||||
// eliminating points from the paths as they are emitted
|
||||
// shorter paths any point eliminated are eliminated as candidates.
|
||||
function emitPath(path) {
|
||||
// test if last point is in the first point's connected list
|
||||
let closed = path[0].group.indexOf(path.peek()) >= 0;
|
||||
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));
|
||||
} else if (path.length > 1) {
|
||||
} else if (path.length > 2) {
|
||||
let gap = path[0].distTo2D(path.peek()).round(4);
|
||||
if (debug) console.log({ open: path.length, gap, path });
|
||||
connect.push(path);
|
||||
if (gap < bridgeMax) {
|
||||
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
|
||||
input.forEach(function(line) {
|
||||
for (let line of input) {
|
||||
p1 = cachedPoint(line.p1);
|
||||
p2 = cachedPoint(line.p2);
|
||||
addConnected(p1,p2);
|
||||
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)});
|
||||
// for each unused point, find the longest non-intersecting path
|
||||
|
||||
for (let point of points) {
|
||||
gl = point.group.length;
|
||||
forks = forks || gl > 2;
|
||||
frays = frays || gl < 2;
|
||||
if (gl > 2) forks.push(point);
|
||||
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
|
||||
if (forks) {
|
||||
// find the path back to the starting point
|
||||
// representing the greatest enclosed area
|
||||
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
|
||||
if (!point.del && point.group.length > 2) {
|
||||
let path = findNextPath(point);
|
||||
if (path) emitPath(path);
|
||||
}
|
||||
} }
|
||||
}
|
||||
|
||||
// process paths with dangling endpoints
|
||||
if (frays) {
|
||||
if (debug) console.log('process frays');
|
||||
for (let point of points) {
|
||||
// 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');
|
||||
// process remaining paths
|
||||
// find the path back to the starting point
|
||||
// representing the greatest enclosed area
|
||||
if (debug) console.log('process norms');
|
||||
for (let point of points) {
|
||||
// must not have been used and be a dangling end
|
||||
if (!point.del) {
|
||||
|
|
@ -649,7 +743,7 @@ export function sliceConnect(input, z, opt = {}) {
|
|||
// progressively connect open polygons within a bridge distance
|
||||
let iter = 1000;
|
||||
let mingap;
|
||||
if (true) do {
|
||||
if (false) do {
|
||||
mingap = Infinity;
|
||||
|
||||
outer: for (let i=0; i<connect.length; i++) {
|
||||
|
|
|
|||
|
|
@ -2,8 +2,18 @@
|
|||
|
||||
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 = [];
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
if (message === undefined || message === null) {
|
||||
return update(true);
|
||||
|
|
@ -19,6 +29,11 @@ function show(message, time) {
|
|||
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) {
|
||||
if (Array.isArray(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) {
|
||||
if (clear) {
|
||||
alerts = [];
|
||||
294
src/kiri/app/api.js
Normal file
294
src/kiri/app/api.js
Normal 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;
|
||||
69
src/kiri/app/color/base.js
Normal file
69
src/kiri/app/color/base.js
Normal 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
58
src/kiri/app/color/cam.js
Normal 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
42
src/kiri/app/color/fdm.js
Normal 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 };
|
||||
28
src/kiri/app/color/laser.js
Normal file
28
src/kiri/app/color/laser.js
Normal 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 };
|
||||
145
src/kiri/app/color/schemes.js
Normal file
145
src/kiri/app/color/schemes.js
Normal 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
23
src/kiri/app/color/sla.js
Normal 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 };
|
||||
|
|
@ -261,7 +261,9 @@ export const conf = {
|
|||
// process defaults FDM:Process
|
||||
p:{
|
||||
antiBacklash: 0,
|
||||
arcTolerance: 0,
|
||||
fdmArcEnabled: false,
|
||||
fdmArcResolution: 1,
|
||||
fdmArcTolerance: 0.005,
|
||||
firstLayerBedTemp: 0,
|
||||
firstLayerBeltBump: 0,
|
||||
firstLayerBeltLead: 3,
|
||||
|
|
@ -335,18 +337,17 @@ export const conf = {
|
|||
sliceShells: 3,
|
||||
sliceSolidify: false,
|
||||
sliceSolidMinArea: 1,
|
||||
sliceSolidMinThick: 0.4,
|
||||
sliceSupportAngle: 50,
|
||||
sliceSupportArea: 0.1,
|
||||
sliceSupportDensity: 0.1,
|
||||
sliceSupportEnable: false,
|
||||
sliceSupportExtra: 0,
|
||||
sliceSupportGap: 1,
|
||||
sliceSupportGrow: 0,
|
||||
sliceSupportNozzle: 0,
|
||||
sliceSupportOffset: 1.0,
|
||||
sliceSupportOutline: true,
|
||||
sliceSupportSize: 5,
|
||||
sliceSupportSpan: 5,
|
||||
sliceSupportType: "disabled",
|
||||
sliceSupportTree: false,
|
||||
sliceTopLayers: 3,
|
||||
sliceZInterleave: false,
|
||||
zHopDistance: 0.2,
|
||||
|
|
@ -443,9 +444,12 @@ export const conf = {
|
|||
camAreaExpand: 0,
|
||||
camAreaRefine: 0,
|
||||
camAreaSmooth: 1,
|
||||
camAreaShadow: false,
|
||||
camAreaDogbones: false,
|
||||
camAreaRevbones: false,
|
||||
camAreaOutline: false,
|
||||
camAreaWalls: false,
|
||||
camAreaZigZag: true,
|
||||
camContourAngle: 85,
|
||||
camContourBottom: false,
|
||||
camContourBridge: 0,
|
||||
|
|
@ -517,6 +521,8 @@ export const conf = {
|
|||
camLatheAngle: 1,
|
||||
camLatheLinear: true,
|
||||
camLatheOver: 0.1,
|
||||
camLatheDown: 0,
|
||||
camLatheExpand: 0,
|
||||
camLatheSpeed: 500,
|
||||
camLatheSpindle: 1000,
|
||||
camLatheTool: 1000,
|
||||
|
|
@ -601,6 +607,7 @@ export const conf = {
|
|||
camTraceLines: false,
|
||||
camTraceMerge: false,
|
||||
camTraceOffOver: 0,
|
||||
camTraceOffset: "none",
|
||||
camTraceOver: 0.5,
|
||||
camTracePlunge: 200,
|
||||
camTraceSpeed: 250,
|
||||
|
|
@ -614,7 +621,6 @@ export const conf = {
|
|||
camZBottom: 0,
|
||||
camZClearance: 1,
|
||||
camZOffset: 0,
|
||||
// camZThru: 0,
|
||||
camZTop: 0,
|
||||
op2: [], // flip ops
|
||||
ops: [], // current ops
|
||||
|
|
@ -998,6 +1004,7 @@ export const conf = {
|
|||
widget: {},
|
||||
// legacy localStorage settings (like octo print)
|
||||
local: {
|
||||
'model.opacity': 1.0,
|
||||
'model.edges': false,
|
||||
'cam.anim.trans': true,
|
||||
'cam.anim.model': false,
|
||||
123
src/kiri/app/conf/dialog.js
Normal file
123
src/kiri/app/conf/dialog.js
Normal 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
|
||||
};
|
||||
|
|
@ -1,20 +1,31 @@
|
|||
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
|
||||
|
||||
import { $, h } from '../../moto/webui.js';
|
||||
import { api } from './api.js';
|
||||
import { conf } from './conf.js';
|
||||
import { codec } from './codec.js';
|
||||
import { consts } from './consts.js';
|
||||
import { space } from '../../moto/space.js';
|
||||
import { local } from '../../data/local.js';
|
||||
import { utils } from './utils.js';
|
||||
import { version } from '../../moto/license.js';
|
||||
import { JSZip } from '../../ext/jszip-esm.js';
|
||||
import { $, h } from '../../../moto/webui.js';
|
||||
import { api } from '../api.js';
|
||||
import { conf } from './defaults.js';
|
||||
import { codec } from '../../core/codec.js';
|
||||
import { consts } from '../consts.js';
|
||||
import { space } from '../../../moto/space.js';
|
||||
import { local } from '../../../data/local.js';
|
||||
import { newWidget } from '../widget.js';
|
||||
import { utils } from '../../core/utils.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 { COLOR } = consts;
|
||||
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 localFilters = js2o(local.getItem(localFilterKey)) || [];
|
||||
|
||||
|
|
@ -115,7 +126,7 @@ function updateSettings(opt = {}) {
|
|||
if (settings.mode === 'FDM' && api.view.is_slice()) {
|
||||
let changes = {};
|
||||
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 add = false;
|
||||
if (layer_lo > 0 || layer_hi < layer_max) {
|
||||
|
|
@ -321,9 +332,10 @@ function updateExtruderFields(device) {
|
|||
onclick() {
|
||||
api.selection.for_widgets(w => {
|
||||
w.anno.extruder = i;
|
||||
const scheme = getColorScheme();
|
||||
w.setColor(
|
||||
api.selection.contains(w) ?
|
||||
COLOR.selected : COLOR.deselected,
|
||||
scheme.widget.selected : scheme.widget.deselected,
|
||||
undefined,
|
||||
false);
|
||||
});
|
||||
|
|
@ -331,7 +343,7 @@ function updateExtruderFields(device) {
|
|||
}),
|
||||
h.div({
|
||||
class: "splat",
|
||||
style: `background-color: #${COLOR.deselected[i].toString(16)}`
|
||||
style: `background-color: #${getColorScheme().widget.deselected[i].toString(16)}`
|
||||
})
|
||||
])));
|
||||
} else {
|
||||
|
|
@ -450,8 +462,8 @@ function updateFieldsFromRange() {
|
|||
let match = 0;
|
||||
let values = {};
|
||||
let restores = Object.clone(overrides);
|
||||
let { layer_lo, layer_hi } = api.var;
|
||||
for (let range of getOverlappingRanges(api.var.layer_lo, api.var.layer_hi)) {
|
||||
const { lo: layer_lo, hi: layer_hi } = api.slider.getRange();
|
||||
for (let range of getOverlappingRanges(layer_lo, layer_hi)) {
|
||||
for (let key of Object.keys(range.fields)) {
|
||||
values[key] = range.fields[key];
|
||||
overrides[key] = settings.process[key];
|
||||
|
|
@ -608,6 +620,7 @@ function settingsImport(data, ask) {
|
|||
work.type = 100;
|
||||
}
|
||||
}
|
||||
codec.setFactory(codec.TYPE.WIDGET, newWidget);
|
||||
codec.decode(data.work).forEach(widget => {
|
||||
api.platform.add(widget, 0, true, true);
|
||||
});
|
||||
|
|
@ -756,11 +769,11 @@ function settingsPrusaConvert(data) {
|
|||
});
|
||||
}
|
||||
|
||||
function setThreaded(bool) {
|
||||
bool = bool ? true : false;
|
||||
settings.controller.threaded = bool;
|
||||
api.event.emit("set.threaded", bool);
|
||||
}
|
||||
// function setThreaded(bool) {
|
||||
// bool = bool ? true : false;
|
||||
// settings.controller.threaded = bool;
|
||||
// api.event.emit("set.threaded", bool);
|
||||
// }
|
||||
|
||||
function setEnableWASM(bool) {
|
||||
bool = bool ? true : false;
|
||||
|
|
@ -786,7 +799,8 @@ const apiSet = {
|
|||
update_fields: updateFieldsFromSettings,
|
||||
update_fields_from_range: updateFieldsFromRange,
|
||||
set_enable_wasm: setEnableWASM,
|
||||
set_threaded: setThreaded,
|
||||
// set_threaded: setThreaded,
|
||||
set_threaded() { console.trace('set_threaded deprecated' )},
|
||||
restore: restoreSettings,
|
||||
export: settingsExport,
|
||||
import: settingsImport,
|
||||
129
src/kiri/app/conf/settings.js
Normal file
129
src/kiri/app/conf/settings.js
Normal 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'> 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,
|
||||
};
|
||||
|
|
@ -1,21 +1,6 @@
|
|||
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
|
||||
|
||||
const COLOR = {
|
||||
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,
|
||||
};
|
||||
// COLOR table has been migrated to src/kiri/app/color/ scheme system
|
||||
|
||||
const LISTS = {
|
||||
shell: [
|
||||
|
|
@ -23,6 +8,11 @@ const LISTS = {
|
|||
{ name: "out-in" },
|
||||
{ name: "alternate" }
|
||||
],
|
||||
support: [
|
||||
{ name: "disabled" },
|
||||
{ name: "automatic" },
|
||||
{ name: "manual" },
|
||||
],
|
||||
start: [
|
||||
{ name: "last" },
|
||||
{ name: "center" },
|
||||
|
|
@ -171,22 +161,17 @@ const PMODES = {
|
|||
TOOLS: 2
|
||||
};
|
||||
|
||||
const PATHS = {
|
||||
work: "../lib/kiri/run/worker.js",
|
||||
pool: "./minion.js"
|
||||
};
|
||||
|
||||
const LAST = 'kiri-last';
|
||||
const SEED = 'kiri-seed';
|
||||
|
||||
export let consts = {
|
||||
COLOR,
|
||||
LAST,
|
||||
LISTS,
|
||||
MODES,
|
||||
PATHS,
|
||||
PMODES,
|
||||
VIEWS,
|
||||
SEED,
|
||||
beltfact: Math.cos(Math.PI / 4)
|
||||
};
|
||||
|
||||
export { COLOR, LISTS, MODES, VIEWS, PATHS, PMODES, SEED };
|
||||
export { LAST, LISTS, MODES, VIEWS, PMODES, SEED };
|
||||
|
|
@ -2,10 +2,10 @@
|
|||
|
||||
import { $, h } from '../../moto/webui.js';
|
||||
import { api } from './api.js';
|
||||
import { conf } from './conf.js';
|
||||
import { conf } from './conf/defaults.js';
|
||||
import { space } from '../../moto/space.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 = {
|
||||
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() {
|
||||
let name = `${getSelectedDevice().replace(/\./g,' ')}`;
|
||||
let code = api.clone(setconf.get().device);
|
||||
|
|
@ -113,6 +117,18 @@ function updateLaserState() {
|
|||
$('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) {
|
||||
api.event.emit('device.select', devicename);
|
||||
try {
|
||||
|
|
@ -213,24 +229,24 @@ function setDeviceCode(code, devicename) {
|
|||
e.disabled = !local;
|
||||
});
|
||||
|
||||
ui.deviceSave.disabled = !local;
|
||||
ui.deviceDelete.disabled = !local;
|
||||
ui.deviceRename.disabled = !local;
|
||||
ui.deviceExport.disabled = !local;
|
||||
ui.deviceAdd.style.display = mode === 'SLA' ? 'none' : '';
|
||||
ui.devices.save.disabled = !local;
|
||||
ui.devices.delete.disabled = !local;
|
||||
ui.devices.rename.disabled = !local;
|
||||
ui.devices.export.disabled = !local;
|
||||
ui.devices.add.style.display = mode === 'SLA' ? 'none' : '';
|
||||
|
||||
if (local) {
|
||||
ui.deviceAdd.innerText = "copy";
|
||||
ui.deviceDelete.style.display = '';
|
||||
ui.deviceRename.style.display = '';
|
||||
ui.deviceExport.style.display = '';
|
||||
ui.devices.add.innerText = "copy";
|
||||
ui.devices.delete.style.display = '';
|
||||
ui.devices.rename.style.display = '';
|
||||
ui.devices.export.style.display = '';
|
||||
} else {
|
||||
ui.deviceAdd.innerText = "customize";
|
||||
ui.deviceDelete.style.display = 'none';
|
||||
ui.deviceRename.style.display = 'none';
|
||||
ui.deviceExport.style.display = 'none';
|
||||
ui.devices.add.innerText = "customize";
|
||||
ui.devices.delete.style.display = 'none';
|
||||
ui.devices.rename.style.display = 'none';
|
||||
ui.devices.export.style.display = 'none';
|
||||
}
|
||||
ui.deviceAdd.disabled = dev.noclone;
|
||||
ui.devices.add.disabled = dev.noclone;
|
||||
|
||||
setconf.update_fields();
|
||||
space.platform.setBelt(isBelt());
|
||||
|
|
@ -277,6 +293,12 @@ function setDeviceCode(code, devicename) {
|
|||
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) {
|
||||
let selected = api.device.get() || devices[0],
|
||||
features = api.feature,
|
||||
|
|
@ -301,7 +323,7 @@ function renderDevices(devices) {
|
|||
|
||||
event.emit('devices.render', devices);
|
||||
|
||||
ui.deviceSave.onclick = function() {
|
||||
ui.devices.save.onclick = function() {
|
||||
event.emit('device.save');
|
||||
api.function.clear();
|
||||
setconf.save();
|
||||
|
|
@ -309,18 +331,18 @@ function renderDevices(devices) {
|
|||
showDevices();
|
||||
api.modal.hide();
|
||||
};
|
||||
ui.deviceAdd.onclick = function() {
|
||||
ui.devices.add.onclick = function() {
|
||||
api.function.clear();
|
||||
cloneDevice();
|
||||
showDevices();
|
||||
};
|
||||
ui.deviceDelete.onclick = function() {
|
||||
ui.devices.delete.onclick = function() {
|
||||
api.function.clear();
|
||||
removeLocalDevice(getSelectedDevice());
|
||||
selectDevice(getModeDevices()[0]);
|
||||
showDevices();
|
||||
};
|
||||
ui.deviceRename.onclick = function() {
|
||||
ui.devices.rename.onclick = function() {
|
||||
api.uc.prompt(`Rename "${selected}`, selected).then(newname => {
|
||||
if (newname) {
|
||||
updateDeviceName(newname);
|
||||
|
|
@ -332,7 +354,7 @@ function renderDevices(devices) {
|
|||
}
|
||||
});
|
||||
};
|
||||
ui.deviceExport.onclick = function(event) {
|
||||
ui.devices.export.onclick = function(event) {
|
||||
const record = {
|
||||
version: api.version,
|
||||
device: selected,
|
||||
|
|
@ -5,12 +5,22 @@ import { space } from '../../moto/space.js';
|
|||
|
||||
const { event } = api;
|
||||
|
||||
/**
|
||||
* Undo/Redo action stack. Each entry contains {undo, redo} action records.
|
||||
* @type {Array<{undo: object, redo: object}>}
|
||||
*/
|
||||
let stack = [];
|
||||
|
||||
/** Current position in the undo/redo stack */
|
||||
let stpos = 0;
|
||||
|
||||
/** Accumulator for drag movements - tracks cumulative x,y offset during selection drag */
|
||||
let moved = { x: 0, y: 0 };
|
||||
|
||||
/** Message ID for the current undo/redo alert message */
|
||||
let msgid;
|
||||
|
||||
event.on("init-done", () => {
|
||||
event.on("load-done", () => {
|
||||
$('undo').onclick = undo;
|
||||
$('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() {
|
||||
stack = [];
|
||||
stpos = 0;
|
||||
|
|
@ -52,6 +65,13 @@ function message(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) {
|
||||
switch (rec.type) {
|
||||
case 'move':
|
||||
|
|
@ -75,6 +95,11 @@ function action(rec) {
|
|||
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) {
|
||||
stack.length = stpos++;
|
||||
stack.push(ur);
|
||||
|
|
@ -2,14 +2,18 @@
|
|||
|
||||
import { $ } from '../../moto/webui.js';
|
||||
import { api } from './api.js';
|
||||
import { client } from './client.js';
|
||||
import { client } from './workers.js';
|
||||
import { local } from '../../data/local.js';
|
||||
import { util } from '../../geo/base.js';
|
||||
import { MODES } from './consts.js';
|
||||
import { LASER as laser_driver } from '../mode/laser/driver.js';
|
||||
import { SLA as sla_client } from '../mode/sla/client.js';
|
||||
import { LASER as laser_driver } from '../mode/laser/init-work.js';
|
||||
import { SLA as sla_client } from '../mode/sla/app/init-ui.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;
|
||||
|
||||
function localGet(key) {
|
||||
|
|
@ -20,6 +24,12 @@ function localSet(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) {
|
||||
let mode = api.mode.get();
|
||||
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) {
|
||||
let alert = api.feature.work_alerts ? api.show.alert("Exporting") : null;
|
||||
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) {
|
||||
client.export(api.conf.get(), (line) => {
|
||||
// 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) {
|
||||
client.export(api.conf.get(), (line) => {
|
||||
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) {
|
||||
localSet('kiri-print-seq', printSeq++);
|
||||
|
||||
|
|
@ -101,9 +136,9 @@ function exportLaserDialog(data, names) {
|
|||
const driver = laser_driver;
|
||||
const { process } = settings;
|
||||
if (process.ctOutStack) {
|
||||
$('print-stl').classList.remove('hide');
|
||||
$('print-obj').classList.remove('hide');
|
||||
} else {
|
||||
$('print-stl').classList.add('hide');
|
||||
$('print-obj').classList.add('hide');
|
||||
}
|
||||
|
||||
function download_svg() {
|
||||
|
|
@ -120,10 +155,10 @@ function exportLaserDialog(data, names) {
|
|||
);
|
||||
}
|
||||
|
||||
function download_stl() {
|
||||
function download_obj() {
|
||||
api.util.download(
|
||||
driver.exportSTL(settings, data),
|
||||
$('print-filename-laser').value + ".stl"
|
||||
driver.exportOBJ(settings, data),
|
||||
$('print-filename-laser').value + ".obj"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -143,17 +178,36 @@ function exportLaserDialog(data, names) {
|
|||
$('print-lines').value = util.comma(segments);
|
||||
$('print-svg').onclick = download_svg;
|
||||
$('print-dxf').onclick = download_dxf;
|
||||
$('print-stl').onclick = download_stl;
|
||||
$('print-obj').onclick = download_obj;
|
||||
$('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) {
|
||||
$(field).onblur = function() {
|
||||
console.log('save', field, 'to', varname);
|
||||
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) {
|
||||
localSet('kiri-print-seq', printSeq++);
|
||||
|
||||
|
|
@ -166,12 +220,7 @@ function exportGCodeDialog(gcode, sections, info, names) {
|
|||
codeproc = settings.device.gcodeProc,
|
||||
octo_host,
|
||||
octo_apik,
|
||||
grid_host,
|
||||
grid_apik,
|
||||
grid_target,
|
||||
grid_targets = {},
|
||||
grid_local,
|
||||
grid_uuid;
|
||||
octo_type;
|
||||
|
||||
// join gcode array into a string
|
||||
gcode = gcode.join('\r\n') + '\r\n';
|
||||
|
|
@ -187,13 +236,18 @@ function exportGCodeDialog(gcode, sections, info, names) {
|
|||
{type:"application/octet-stream"});
|
||||
}
|
||||
|
||||
function sendto_octoprint() {
|
||||
if (!(octo_host && octo_apik)) return;
|
||||
function printToHost() {
|
||||
sendToHost(true);
|
||||
}
|
||||
|
||||
function sendToHost(start) {
|
||||
if (!(octo_host && octo_apik && octo_type)) return;
|
||||
|
||||
let form = new FormData(),
|
||||
ajax = new XMLHttpRequest(),
|
||||
host = octo_host.value.toLowerCase(),
|
||||
apik = octo_apik.value;
|
||||
apik = octo_apik.value,
|
||||
type = octo_type.value;
|
||||
|
||||
if (host.indexOf("http") !== 0) {
|
||||
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-apik', apik.trim());
|
||||
localSet('octo-type', type.trim());
|
||||
|
||||
filename = $('print-filename').value;
|
||||
form.append("file", getBlob(), filename+"."+fileext);
|
||||
filename = $('print-filename').value + "." + fileext;
|
||||
form.append("file", getBlob(), filename);
|
||||
ajax.onreadystatechange = function() {
|
||||
console.log({ ajax_readyState: ajax.readyState });
|
||||
if (ajax.readyState === 4) {
|
||||
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) {
|
||||
api.modal.hide();
|
||||
} 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);
|
||||
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);
|
||||
};
|
||||
ajax.upload.addEventListener('progress', function(evt) {
|
||||
ajax.upload.addEventListener('progress', (evt) => {
|
||||
console.log({ ajax_progress: evt });
|
||||
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) {
|
||||
ajax.setRequestHeader("X-Api-Key", apik);
|
||||
}
|
||||
|
|
@ -275,7 +346,8 @@ function exportGCodeDialog(gcode, sections, info, names) {
|
|||
api.modal.show('xany');
|
||||
let set = api.conf.get();
|
||||
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;
|
||||
$('code-preview-head').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-filesize').value = util.comma(info.bytes);
|
||||
$('print-length').value = Math.round(info.distance);
|
||||
if (set.controller.devel) {
|
||||
$('code-preview-textarea').style.height = "30em";
|
||||
if ((fdm || cam) && preview && set.controller.devel) {
|
||||
document.body.classList.add('devel');
|
||||
}
|
||||
calcTime();
|
||||
if (fdm) {
|
||||
|
|
@ -295,6 +367,7 @@ function exportGCodeDialog(gcode, sections, info, names) {
|
|||
// persist fields when changed
|
||||
bindField('octo-host', 'octo-host');
|
||||
bindField('octo-apik', 'octo-apik');
|
||||
bindField('octo-type', 'octo-type');
|
||||
|
||||
// in cam mode, show zip file option
|
||||
let downloadZip = $('print-zip');
|
||||
|
|
@ -336,6 +409,13 @@ function exportGCodeDialog(gcode, sections, info, names) {
|
|||
// let bnds = settings.bounds;
|
||||
// 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]) {
|
||||
let now = new Date();
|
||||
let ymd = [
|
||||
|
|
@ -489,21 +569,23 @@ function exportGCodeDialog(gcode, sections, info, names) {
|
|||
})
|
||||
};
|
||||
|
||||
// octoprint setup
|
||||
$('send-to-octohead').style.display = octo ? '' : 'none';
|
||||
$('send-to-octoprint').style.display = octo ? '' : 'none';
|
||||
// remote host (klipper fluidd / octoprint)
|
||||
$('send-remote-head').style.display = octo ? '' : 'none';
|
||||
$('send-remote-print').style.display = octo ? '' : 'none';
|
||||
if (octo) try {
|
||||
$('print-octoprint').onclick = sendto_octoprint;
|
||||
$('send-remote').onclick = sendToHost;
|
||||
$('print-remote').onclick = printToHost;
|
||||
octo_host = $('octo-host');
|
||||
octo_apik = $('octo-apik');
|
||||
octo_type = $('octo-type');
|
||||
if (MODE === MODES.CAM) {
|
||||
$('send-to-octohead').style.display = 'none';
|
||||
$('send-to-octoprint').style.display = 'none';
|
||||
$('send-remote-head').style.display = 'none';
|
||||
$('send-remote-print').style.display = 'none';
|
||||
} else {
|
||||
$('send-to-octohead').style.display = '';
|
||||
$('send-to-octoprint').style.display = '';
|
||||
$('send-remote-head').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) {
|
||||
$('ophost').style.display = 'none';
|
||||
$('opapik').style.display = 'none';
|
||||
|
|
@ -511,6 +593,7 @@ function exportGCodeDialog(gcode, sections, info, names) {
|
|||
}
|
||||
octo_host.value = localGet('octo-host') || '';
|
||||
octo_apik.value = localGet('octo-apik') || '';
|
||||
octo_type.value = localGet('octo-type') || '';
|
||||
} catch (e) { console.log(e) }
|
||||
|
||||
// preview of the generated GCODE (first 64k max)
|
||||
|
|
@ -1,20 +1,39 @@
|
|||
/** 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 { THREE } from '../../ext/three.js';
|
||||
import { space } from '../../moto/space.js';
|
||||
|
||||
const { Vector3, Quaternion } = THREE;
|
||||
|
||||
/** X-axis unit vector for rotation calculations */
|
||||
const XAXIS = new THREE.Vector3(1,0,0);
|
||||
|
||||
/** Z-axis unit vector for rotation calculations */
|
||||
const ZAXIS = new THREE.Vector3(0,0,1);
|
||||
|
||||
// circle
|
||||
/** Circle mesh geometry for face indicator */
|
||||
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});
|
||||
|
||||
/** Combined mesh for face indicator (circle + outline) */
|
||||
let pmesh = new THREE.Mesh(pgeo, pmat);
|
||||
|
||||
// circle outline
|
||||
// Build circle outline from geometry indices
|
||||
let pi = pgeo.index.array;
|
||||
let pp = pgeo.attributes.position.array;
|
||||
let np = [];
|
||||
|
|
@ -36,19 +55,39 @@ let wmat = new THREE.LineBasicMaterial({ color: 0x6699cc });
|
|||
let wmesh = new THREE.LineSegments(wgeo, wmat);
|
||||
pmesh.add(wmesh);
|
||||
|
||||
/** Alert handle for "ESC to end" message */
|
||||
let alert;
|
||||
|
||||
/** Last object (widget mesh) the indicator was attached to */
|
||||
let lastobj;
|
||||
|
||||
/** Last face that was hovered over */
|
||||
let lastface;
|
||||
|
||||
/** Whether a tool is currently active */
|
||||
let enabled = false;
|
||||
|
||||
/** Name of current operation for display */
|
||||
let opName = 'lay flat';
|
||||
|
||||
/** Callback to execute when face is selected */
|
||||
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() {
|
||||
let q = new Quaternion().setFromUnitVectors(lastface.normal, new Vector3(0,0,-1));
|
||||
api.selection.rotate(q);
|
||||
// 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() {
|
||||
// Project the face normal onto the XY plane (ignore Z component)
|
||||
let projectedNormal = new Vector3(lastface.normal.x, lastface.normal.y, 0).normalize();
|
||||
|
|
@ -67,11 +106,21 @@ function onLeftySelect() {
|
|||
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() {
|
||||
api.event.emit('tool.mesh.face-normal', lastface.normal);
|
||||
// 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() {
|
||||
if (enabled) {
|
||||
endIt();
|
||||
|
|
@ -86,6 +135,10 @@ function startIt() {
|
|||
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() {
|
||||
if (enabled) {
|
||||
api.hide.alert(alert);
|
||||
|
|
@ -96,6 +149,9 @@ function endIt() {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove face indicator mesh from scene.
|
||||
*/
|
||||
function cleanup() {
|
||||
if (lastobj) {
|
||||
lastobj.remove(pmesh);
|
||||
|
|
@ -105,30 +161,52 @@ function cleanup() {
|
|||
|
||||
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';
|
||||
onDone = fn;
|
||||
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';
|
||||
onDone = onFaceUpSelect;
|
||||
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';
|
||||
onDone = onLayFlatSelect;
|
||||
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';
|
||||
onDone = onLeftySelect;
|
||||
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() {
|
||||
let cam = space.internals().camera;
|
||||
let dist = cam.position.distanceTo(pmesh.position);
|
||||
|
|
@ -137,8 +215,6 @@ function scale() {
|
|||
pmesh.scale.set(scale,scale,scale);
|
||||
}
|
||||
|
||||
api.event.on("space.view.zoom", scale);
|
||||
|
||||
api.event.on('mouse.hover', (ev) => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
|
|
@ -186,5 +262,16 @@ api.event.on('mouse.hover.up', (ev) => {
|
|||
endIt();
|
||||
});
|
||||
|
||||
|
||||
export { onLayFlatSelect, onFaceUpSelect, startIt, endIt, cleanup, scale, onDone };
|
||||
export {
|
||||
cleanup,
|
||||
endIt,
|
||||
onDone,
|
||||
onFaceUpSelect,
|
||||
onLayFlatSelect,
|
||||
scale,
|
||||
startFaceUp,
|
||||
startFocus,
|
||||
startIt,
|
||||
startLeftAlign,
|
||||
startLayFlat,
|
||||
};
|
||||
159
src/kiri/app/file-ops.js
Normal file
159
src/kiri/app/file-ops.js
Normal 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
|
||||
};
|
||||
|
|
@ -1,6 +1,15 @@
|
|||
/** 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 {
|
||||
/**
|
||||
* Create a new Files store
|
||||
* @param {object} indexdb - IndexedDB wrapper instance
|
||||
*/
|
||||
constructor(indexdb) {
|
||||
let store = this;
|
||||
this.db = indexdb;
|
||||
|
|
@ -47,10 +56,21 @@ class Files {
|
|||
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) {
|
||||
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) {
|
||||
// triggers refresh callback
|
||||
this.files[name] = {
|
||||
|
|
@ -91,6 +111,13 @@ class Files {
|
|||
.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) {
|
||||
if (!this.files[name]) return callback({error: 'no such file'});
|
||||
if (!newname || newname == name) return callback({error: 'invalid new name'});
|
||||
|
|
@ -146,6 +173,12 @@ class Files {
|
|||
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) {
|
||||
this.db.keys(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) {
|
||||
store.db.put('files', store.files);
|
||||
notifyFileListeners(store);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all registered listeners of file list changes.
|
||||
* @param {Files} store - Files instance
|
||||
*/
|
||||
function notifyFileListeners(store) {
|
||||
for (let i=0; i<store.listeners.length; i++) {
|
||||
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) {
|
||||
return new Files(indexdb);
|
||||
};
|
||||
|
|
@ -1,12 +1,38 @@
|
|||
/** 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 { load } from '../../load/file.js';
|
||||
import { newWidget } from './widget.js';
|
||||
import { VIEWS } from './consts.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 => {
|
||||
const { conf, event, feature, platform, settings, show } = api;
|
||||
|
||||
|
|
@ -89,6 +115,9 @@ window.addEventListener('message', msg => {
|
|||
let bin = data.parse;
|
||||
let widget;
|
||||
switch ((data.type || 'stl').toLowerCase()) {
|
||||
case 'gcode':
|
||||
api.function.parse(bin, 'gcode');
|
||||
break;
|
||||
case 'stl':
|
||||
if (!bin.buffer) bin = new Float32Array(bin).buffer;
|
||||
new load.STL().parse(bin, vertices => {
|
||||
|
|
@ -1,14 +1,43 @@
|
|||
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
|
||||
|
||||
import { api } from './api.js';
|
||||
import { client } from './client.js';
|
||||
import { codec } from './codec.js';
|
||||
import { client } from './workers.js';
|
||||
import { codec } from '../core/codec.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';
|
||||
|
||||
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) {
|
||||
const { conf, event, feature, hide, mode, view, platform, show, stacks } = api;
|
||||
|
||||
|
|
@ -28,6 +57,10 @@ function prepareSlices(callback, scale = 1, offset = 0) {
|
|||
callback = preparePreview;
|
||||
}
|
||||
|
||||
order = api.selection.count() ?
|
||||
Object.assign({}, ...api.selection.widgets().map((w,i) => ({[w.id]:i}))) :
|
||||
undefined;
|
||||
|
||||
const widgets = api.widgets.all();
|
||||
const settings = conf.get();
|
||||
const { device, process, controller } = settings;
|
||||
|
|
@ -73,7 +106,8 @@ function prepareSlices(callback, scale = 1, offset = 0) {
|
|||
|
||||
let totalProgress;
|
||||
|
||||
api.widgets.setOpacity(COLOR.slicing_opacity);
|
||||
const scheme = getColorScheme();
|
||||
api.widgets.setOpacity(scheme.views.SLICE.slicing_opacity);
|
||||
|
||||
let segtimes = {},
|
||||
segNumber = 0,
|
||||
|
|
@ -87,7 +121,8 @@ function prepareSlices(callback, scale = 1, offset = 0) {
|
|||
for (let widget of toSlice) {
|
||||
widget.belt = null;
|
||||
widget.stats.progress = 0;
|
||||
widget.setColor(COLOR.slicing);
|
||||
const scheme = getColorScheme();
|
||||
widget.setColor(0xffaaaa); // slicing color (from old COLOR.slicing)
|
||||
let { extruder } = widget.anno;
|
||||
if (extruder >= 0) {
|
||||
extruders[extruder] = extruder;
|
||||
|
|
@ -114,13 +149,17 @@ function prepareSlices(callback, scale = 1, offset = 0) {
|
|||
client.rotate(settings);
|
||||
}
|
||||
|
||||
function sliceSetup() {
|
||||
client.slicePre(settings, sliceNext);
|
||||
}
|
||||
|
||||
function sliceNext() {
|
||||
if (toSlice.length) {
|
||||
// while more widgets to slice
|
||||
sliceWidget(toSlice.shift())
|
||||
} else {
|
||||
// once all slicing done, run sliceAll() once for all widgets
|
||||
client.sliceAll(settings, sliceDone);
|
||||
// once all slicing done, run slicePost() once for all widgets
|
||||
client.slicePost(settings, sliceDone);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -134,8 +173,8 @@ function prepareSlices(callback, scale = 1, offset = 0) {
|
|||
let mark = Date.now();
|
||||
if (lastMsg) {
|
||||
let key = slicing.length > 1 ?
|
||||
`${widget.id}_${segNumber++}_${lastMsg}` :
|
||||
`${segNumber++}_${lastMsg}`
|
||||
`${widget.id} ${segNumber++} ${lastMsg}` :
|
||||
`${segNumber++} ${lastMsg}`
|
||||
segtimes[key] = mark - startTime;
|
||||
}
|
||||
lastMsg = msg;
|
||||
|
|
@ -159,8 +198,8 @@ function prepareSlices(callback, scale = 1, offset = 0) {
|
|||
// update segment time
|
||||
if (lastMsg) {
|
||||
let key = slicing.length > 1 ?
|
||||
`${widget.id}_${segNumber++}_${lastMsg}` :
|
||||
`${segNumber++}_${lastMsg}`
|
||||
`${widget.id} ${segNumber++} ${lastMsg}` :
|
||||
`${segNumber++} ${lastMsg}`
|
||||
segtimes[`${key}`] = mark - startTime;
|
||||
}
|
||||
event.emit('slice', settings.mode);
|
||||
|
|
@ -241,8 +280,8 @@ function prepareSlices(callback, scale = 1, offset = 0) {
|
|||
for (let widget of slicing) {
|
||||
// on done
|
||||
let key = slicing.length > 1 ?
|
||||
`${widget.id}_${segNumber++}_draw` :
|
||||
`${segNumber++}_draw`
|
||||
`${widget.id}_${segNumber++} draw` :
|
||||
`${segNumber++} draw`
|
||||
segtimes[`${key}`] = widget.render(widget.stack);
|
||||
// rotate stack for belt beds
|
||||
if (widget.belt) {
|
||||
|
|
@ -250,9 +289,10 @@ function prepareSlices(callback, scale = 1, offset = 0) {
|
|||
}
|
||||
if (scale === 1) {
|
||||
// clear wireframe
|
||||
widget.setWireframe(false, COLOR.wireframe, COLOR.wireframe_opacity);
|
||||
widget.setOpacity(camOrLaser ? COLOR.cam_sliced_opacity : COLOR.sliced_opacity);
|
||||
widget.setColor(COLOR.deselected);
|
||||
const scheme = getColorScheme();
|
||||
widget._setWireframe(false, scheme.wireframe.color, scheme.wireframe.opacity);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -273,6 +313,7 @@ function prepareSlices(callback, scale = 1, offset = 0) {
|
|||
// print stats
|
||||
segtimes.total = Date.now() - mark;
|
||||
console.log(segtimes);
|
||||
api.visuals.update_stats(segtimes);
|
||||
if (callback && typeof callback === 'function') {
|
||||
callback();
|
||||
}
|
||||
|
|
@ -285,10 +326,22 @@ function prepareSlices(callback, scale = 1, offset = 0) {
|
|||
space.refresh();
|
||||
}
|
||||
|
||||
// kick off slicing chain
|
||||
sliceNext();
|
||||
// do any global setup / state management
|
||||
// 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) {
|
||||
const { conf, event, feature, hide, mode, view, platform, show, stacks } = api;
|
||||
const widgets = api.widgets.all();
|
||||
|
|
@ -325,10 +378,12 @@ function preparePreview(callback, scale = 1, offset = 0) {
|
|||
event.emit('preview.begin', pMode);
|
||||
|
||||
if (isCam) {
|
||||
api.widgets.setOpacity(isDark ? COLOR.cam_preview_opacity_dark : COLOR.cam_preview_opacity);
|
||||
api.widgets.setColor(isDark ? COLOR.cam_preview_dark : COLOR.cam_preview);
|
||||
const scheme = getColorScheme();
|
||||
api.widgets.setOpacity(scheme.views.PREVIEW.preview_opacity);
|
||||
api.widgets.setColor(scheme.views.PREVIEW.preview);
|
||||
} 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(),
|
||||
|
|
@ -341,6 +396,7 @@ function preparePreview(callback, scale = 1, offset = 0) {
|
|||
// pass preview mode to worker
|
||||
settings.pmode = feature.pmode;
|
||||
settings.render = true;
|
||||
settings.order = order;
|
||||
|
||||
client.prepare(settings, (progress, message, layer) => {
|
||||
if (layer) {
|
||||
|
|
@ -349,7 +405,7 @@ function preparePreview(callback, scale = 1, offset = 0) {
|
|||
if (message && message !== lastMsg) {
|
||||
const mark = Date.now();
|
||||
if (lastMsg) {
|
||||
segtimes[`${segNumber++}_${lastMsg}`] = mark - startTime;
|
||||
segtimes[`${segNumber++} ${lastMsg}`] = mark - startTime;
|
||||
}
|
||||
lastMsg = message;
|
||||
startTime = mark;
|
||||
|
|
@ -367,7 +423,7 @@ function preparePreview(callback, scale = 1, offset = 0) {
|
|||
}
|
||||
|
||||
if (lastMsg) {
|
||||
segtimes[`${segNumber++}_${lastMsg}`] = Date.now() - startTime;
|
||||
segtimes[`${segNumber++} ${lastMsg}`] = Date.now() - startTime;
|
||||
}
|
||||
|
||||
show.progress(0);
|
||||
|
|
@ -393,12 +449,13 @@ function preparePreview(callback, scale = 1, offset = 0) {
|
|||
stack.obj.rotate(widgets[0].belt);
|
||||
}
|
||||
api.hide.alert(alert);
|
||||
segtimes[`${segNumber}_draw`] = Date.now() - startTime;
|
||||
segtimes[`${segNumber} draw`] = Date.now() - startTime;
|
||||
}
|
||||
|
||||
// print stats
|
||||
segtimes.total = Date.now() - mark;
|
||||
console.log(segtimes);
|
||||
api.visuals.update_stats(segtimes);
|
||||
|
||||
event.emit('print', 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() {
|
||||
if (!window.SharedArrayBuffer) {
|
||||
api.alerts.show("The security context of this");
|
||||
|
|
@ -437,6 +498,12 @@ function prepareAnimation() {
|
|||
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() {
|
||||
const settings = api.conf.get();
|
||||
const argsave = arguments;
|
||||
|
|
@ -449,12 +516,23 @@ function prepareExport() {
|
|||
exportFile(...argsave);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel running worker operation by restarting the worker.
|
||||
*/
|
||||
function cancelWorker() {
|
||||
if (client.isBusy()) {
|
||||
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) {
|
||||
const { conf, event, show, stacks, widgets, view } = api;
|
||||
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() {
|
||||
complete = {};
|
||||
}
|
||||
24
src/kiri/app/groups.js
Normal file
24
src/kiri/app/groups.js
Normal 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
36
src/kiri/app/help.js
Normal 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
103
src/kiri/app/image.js
Normal 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> 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> 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
134
src/kiri/app/init/bind.js
Normal 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
546
src/kiri/app/init/input.js
Normal 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
69
src/kiri/app/init/lang.js
Normal 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
324
src/kiri/app/init/sync.js
Normal 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;
|
||||
}
|
||||
|
|
@ -1,31 +1,62 @@
|
|||
/** 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 { api } from './api.js';
|
||||
|
||||
let DOC = self.document,
|
||||
/** Callback for input changes */
|
||||
inputAction = null,
|
||||
/** Previous addTo value for nesting */
|
||||
lastAddTo = null,
|
||||
/** Current group array */
|
||||
lastGroup = null,
|
||||
/** Last created div container */
|
||||
lastDiv = null,
|
||||
/** Current container to add elements to */
|
||||
addTo = null,
|
||||
/** Alternative binding target for input actions */
|
||||
bindTo = null,
|
||||
/** Map of group name to array of elements */
|
||||
groups = {},
|
||||
/** Sticky state prevents auto-hide on blur */
|
||||
groupSticky = false,
|
||||
/** Current group name */
|
||||
groupName = undefined,
|
||||
heads = {}, // hideable group heads (clickable label)
|
||||
hidden = {}, // hidden groups (by name)
|
||||
/** Collapsible group headers (clickable label) */
|
||||
heads = {},
|
||||
/** Hidden groups by name */
|
||||
hidden = {},
|
||||
/** Elements with mode visibility rules */
|
||||
hasModes = [],
|
||||
/** Elements with unit conversion setters */
|
||||
setters = [],
|
||||
/** Last mode set for visibility filtering */
|
||||
lastMode = null,
|
||||
/** Last expert mode state */
|
||||
lastExpert = true,
|
||||
/** Prefix for element IDs */
|
||||
prefix = "tab",
|
||||
/** Unit scale multiplier for conversions */
|
||||
units = 1,
|
||||
/** Last changed input element */
|
||||
lastChange = null,
|
||||
/** Last clicked button */
|
||||
lastBtn = null,
|
||||
/** Last clicked text element */
|
||||
lastTxt = null,
|
||||
/** Last shown popup */
|
||||
lastPop = null;
|
||||
|
||||
/**
|
||||
* UI component factory and utilities.
|
||||
* Provides chainable builder pattern and component creation functions.
|
||||
*/
|
||||
export const UI = {
|
||||
prefix: function(pre) { prefix = pre; 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) {
|
||||
hidden = map;
|
||||
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) {
|
||||
if (Array.isArray(obj)) {
|
||||
for (let o of obj) onBlur(o, fn);
|
||||
|
|
@ -100,14 +140,34 @@ function onBlur(obj, 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) {
|
||||
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) {
|
||||
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 = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let { feature } = api;
|
||||
|
|
@ -148,9 +208,11 @@ function confirm(message, buttons, input, opt = {}) {
|
|||
html.appendAll(opt.post);
|
||||
}
|
||||
dialog.innerHTML = html.join('');
|
||||
dialog.onclose = () => {
|
||||
feature.on_key = onkey_save;
|
||||
};
|
||||
function done(value) {
|
||||
dialog.close();
|
||||
feature.on_key = onkey_save;
|
||||
if (value !== undefined) {
|
||||
setTimeout(() => { resolve(value) }, 150);
|
||||
}
|
||||
|
|
@ -307,6 +369,12 @@ function addCollapsableElement(parent, options = {}) {
|
|||
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) {
|
||||
return function(v) {
|
||||
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() {
|
||||
let nv = this.value !== '' ? parseInt(this.value) : null;
|
||||
if (isNaN(nv)) nv = 0;
|
||||
|
|
@ -325,6 +398,11 @@ function toInt() {
|
|||
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() {
|
||||
let nv = this.value !== '' ? parseFloat(this.value) : null;
|
||||
if (nv !== null && this.bound) nv = this.bound(nv);
|
||||
|
|
@ -335,6 +413,11 @@ function toFloat() {
|
|||
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() {
|
||||
let nv = this.value !== '' ? this.value.split(',').map(v => parseFloat(v)) : null;
|
||||
console.log({ toFloatArray: nv });
|
||||
|
|
@ -343,6 +426,11 @@ function toFloatArray() {
|
|||
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(){
|
||||
let nv = this.value !== '' ? parseFloat(this.value) : null;
|
||||
if (nv !== null && this.bound) nv = this.bound(nv);
|
||||
|
|
@ -375,6 +463,12 @@ function newLabel(text, opt = {}) {
|
|||
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 = {}) {
|
||||
let value = DOC.createElement('input');
|
||||
value.setAttribute("size", size);
|
||||
|
|
@ -452,6 +546,13 @@ function newDiv(opt = {}) {
|
|||
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 = {}) {
|
||||
let div = DOC.createElement('details');
|
||||
div.setAttribute('class', opt.class || 'f-col');
|
||||
|
|
@ -467,6 +568,11 @@ function newExpand(label, opt = {}) {
|
|||
div.removeAttribute('open');
|
||||
};
|
||||
|
||||
if (opt.hover) {
|
||||
div.addEventListener('mouseenter', event => opt.hover('enter', event));
|
||||
div.addEventListener('mouseleave', event => opt.hover('leave', event));
|
||||
}
|
||||
|
||||
lastAddTo = addTo;
|
||||
addTo.appendChild(div);
|
||||
addTo = div;
|
||||
|
|
@ -474,6 +580,10 @@ function newExpand(label, opt = {}) {
|
|||
return div;
|
||||
}
|
||||
|
||||
/**
|
||||
* End expand context, restoring previous addTo.
|
||||
* @returns {HTMLElement} Restored addTo element
|
||||
*/
|
||||
function endExpand() {
|
||||
addTo = lastAddTo;
|
||||
return addTo;
|
||||
|
|
@ -612,6 +722,15 @@ function newText(label, options) {
|
|||
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 = {}) {
|
||||
let row = newDiv(opt),
|
||||
hide = opt.hide,
|
||||
|
|
@ -621,6 +740,14 @@ function newInput(label, opt = {}) {
|
|||
ip = height > 1 ? DOC.createElement('textarea') : DOC.createElement('input');
|
||||
|
||||
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.setAttribute("class", opt.class || "var-row");
|
||||
if (height > 1) {
|
||||
|
|
@ -680,6 +807,9 @@ function newInput(label, opt = {}) {
|
|||
if (opt.trigger) {
|
||||
refresh(opt.trigger === 1 || opt.trigger === true);
|
||||
}
|
||||
if (opt.post) {
|
||||
opt.post();
|
||||
}
|
||||
});
|
||||
if (opt.units) {
|
||||
addUnits(ip, opt.round || 3);
|
||||
|
|
@ -706,6 +836,12 @@ function addUnits(input, round) {
|
|||
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) {
|
||||
let row = newDiv(options),
|
||||
ip = DOC.createElement('input'),
|
||||
|
|
@ -732,6 +868,14 @@ function newRange(label, options) {
|
|||
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) {
|
||||
let row = newDiv(options),
|
||||
ip = DOC.createElement('select'),
|
||||
|
|
@ -774,6 +918,14 @@ function newSelect(label, options = {}, source) {
|
|||
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 = {}) {
|
||||
let row = newDiv(opt),
|
||||
ip = DOC.createElement('input'),
|
||||
|
|
@ -828,7 +980,15 @@ function newBlank(options) {
|
|||
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 = {}) {
|
||||
let b = DOC.createElement('button');
|
||||
|
||||
|
|
@ -866,9 +1026,19 @@ function newButton(label, action, opt = {}) {
|
|||
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) {
|
||||
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);
|
||||
if (options && options.class) {
|
||||
options.class.split(' ').forEach(ce => {
|
||||
450
src/kiri/app/keyboard.js
Normal file
450
src/kiri/app/keyboard.js
Normal 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();
|
||||
|
|
@ -1,10 +1,23 @@
|
|||
/** 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: {} };
|
||||
|
||||
/** Default language code */
|
||||
const KDFL = 'en-us';
|
||||
|
||||
/** Currently selected language code from browser */
|
||||
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) {
|
||||
if (!key) {
|
||||
return KDFL;
|
||||
|
|
@ -23,10 +36,21 @@ LANG.map = function(key) {
|
|||
return KDFL;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get current browser language setting.
|
||||
* @returns {string} Browser language code
|
||||
*/
|
||||
LANG.get = function() {
|
||||
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() {
|
||||
let map, key, keys = [...arguments];
|
||||
// provide default if none given
|
||||
|
|
@ -3,11 +3,19 @@
|
|||
import { newPolygon, Polygon } from '../../geo/polygon.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 {
|
||||
constructor() {
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize or reset layers, profiles, and statistics.
|
||||
*/
|
||||
init() {
|
||||
this.layers = {};
|
||||
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) {
|
||||
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) {
|
||||
this.rotation = { x, y, z };
|
||||
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) {
|
||||
this.position = { x, y, z };
|
||||
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) {
|
||||
let layers = this.layers;
|
||||
if (typeof(colors) === 'number') {
|
||||
|
|
@ -65,13 +99,24 @@ export class Layers {
|
|||
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) {
|
||||
this.current.lines.push(p1, p2);
|
||||
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) {
|
||||
if (options) {
|
||||
// the open option encodes lines as open polygons
|
||||
|
|
@ -92,12 +137,23 @@ export class Layers {
|
|||
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) {
|
||||
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) {
|
||||
if (!polys) {
|
||||
return this;
|
||||
|
|
@ -134,8 +190,13 @@ export class Layers {
|
|||
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) {
|
||||
const faces = this.current.faces;
|
||||
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) {
|
||||
const opts = options || {};
|
||||
const offset = opts.offset || 1;
|
||||
|
|
@ -200,7 +267,14 @@ export class Layers {
|
|||
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) {
|
||||
const opts = options || {};
|
||||
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) {
|
||||
if (Array.isArray(polys)) {
|
||||
return POLY.flatten(polys.clone(true), [], true);
|
||||
55
src/kiri/app/local.js
Normal file
55
src/kiri/app/local.js
Normal 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
119
src/kiri/app/modal.js
Normal 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
148
src/kiri/app/mode.js
Normal 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
218
src/kiri/app/mouse.js
Normal 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
555
src/kiri/app/paint.js
Normal 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
|
||||
};
|
||||
|
|
@ -1,32 +1,65 @@
|
|||
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
|
||||
|
||||
import { $, h } from '../../moto/webui.js';
|
||||
import { ajax, js2o } from '../core/utils.js';
|
||||
import { api } from './api.js';
|
||||
import { ajax, js2o } from './utils.js';
|
||||
import { base } from '../../geo/base.js';
|
||||
import { load } from '../../load/file.js';
|
||||
import { space } from '../../moto/space.js';
|
||||
import { Widget, newWidget } from './widget.js';
|
||||
import { MODES } from './consts.js';
|
||||
import { colorSchemeRegistry } from './color/schemes.js';
|
||||
import { load as file_load } from '../../load/file.js';
|
||||
import { newBounds } from '../../geo/bounds.js';
|
||||
import { Packer } from './pack.js';
|
||||
|
||||
import { COLOR, MODES } from './consts.js';
|
||||
import { space } from '../../moto/space.js';
|
||||
import { THREE } from '../../ext/three.js';
|
||||
import { Widget, newWidget } from './widget.js';
|
||||
|
||||
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;
|
||||
|
||||
/** Flag indicating if grouping operation is in progress */
|
||||
let grouping = false;
|
||||
|
||||
/** Maximum Z height of all widgets on platform */
|
||||
let topZ = 0;
|
||||
|
||||
/**
|
||||
* Get current settings object.
|
||||
* @returns {object} Current configuration
|
||||
*/
|
||||
function current() {
|
||||
return api.conf.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current mode ID constant.
|
||||
* @returns {number} Mode ID
|
||||
*/
|
||||
function get_mode() {
|
||||
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) {
|
||||
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 { showRulers, units } = controller;
|
||||
|
||||
|
|
@ -124,17 +164,18 @@ function platformUpdateSize(updateDark = true) {
|
|||
gridMinor = unitMM ? 5 : 25.4 / 10;
|
||||
|
||||
if (updateDark) {
|
||||
const scheme = getColorScheme();
|
||||
if (controller.dark) {
|
||||
space.platform.set({ light: 0.08 });
|
||||
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.sky.set({ color: 0, ambient: { intensity: 0.6 } });
|
||||
document.body.classList.add('dark');
|
||||
} else {
|
||||
space.platform.set({ light: 0.08 });
|
||||
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.sky.set({ color: 0xffffff, ambient: { intensity: 1.1 } });
|
||||
document.body.classList.remove('dark');
|
||||
|
|
@ -146,6 +187,10 @@ function platformUpdateSize(updateDark = true) {
|
|||
platform.update_origin();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update topZ to track maximum Z height of all widgets.
|
||||
* Used for belt mode and other Z-dependent calculations.
|
||||
*/
|
||||
function platformUpdateMidZ() {
|
||||
topZ = 0;
|
||||
api.widgets.each(widget => {
|
||||
|
|
@ -154,7 +199,12 @@ function platformUpdateMidZ() {
|
|||
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 MODE = get_mode();
|
||||
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() {
|
||||
const settings = current();
|
||||
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;
|
||||
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();
|
||||
if (!setbounds)
|
||||
api.widgets.each(widget => {
|
||||
|
|
@ -231,17 +299,26 @@ function platformUpdateBounds() {
|
|||
}
|
||||
current().bounds = bounds;
|
||||
platformUpdateStock();
|
||||
platformUpdateTopZ();
|
||||
update_top_z();
|
||||
platformUpdateMidZ();
|
||||
platformUpdateOrigin(false);
|
||||
update_origin(false);
|
||||
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;
|
||||
}
|
||||
|
||||
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 { device } = settings;
|
||||
|
|
@ -253,21 +330,32 @@ function platformUpdateSelected() {
|
|||
let b = $(`sel-ext-${i}`);
|
||||
if (b) b.classList.remove('pop-sel');
|
||||
}
|
||||
const scheme = getColorScheme();
|
||||
selection.for_widgets(w => {
|
||||
w.setColor(COLOR.selected);
|
||||
w.setColor(scheme.widget.selected);
|
||||
let ext = api.widgets.annotate(w.id).extruder || 0;
|
||||
let b = $(`sel-ext-${ext}`);
|
||||
if (b) b.classList.add('pop-sel');
|
||||
w.saveState();
|
||||
}, true);
|
||||
} else {
|
||||
const scheme = getColorScheme();
|
||||
selection.for_widgets(w => {
|
||||
w.setColor(COLOR.selected);
|
||||
w.setColor(scheme.widget.selected);
|
||||
}, 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;
|
||||
|
||||
if (!view.is_arrange()) {
|
||||
|
|
@ -277,7 +365,7 @@ function platformSelect(widget, shift, recurse = true) {
|
|||
// apply select to entire group
|
||||
if (recurse && widget && widget.group.length > 1) {
|
||||
for (let w of widget.group) {
|
||||
platformSelect(w, true, false);
|
||||
select(w, true, false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -299,7 +387,8 @@ function platformSelect(widget, shift, recurse = true) {
|
|||
}
|
||||
selection.add(widget);
|
||||
event.emit('widget.select', widget);
|
||||
widget.setColor(COLOR.selected);
|
||||
const scheme = getColorScheme();
|
||||
widget.setColor(scheme.widget.selected);
|
||||
$(`ws-${widget.id}`)?.classList.add('selected');
|
||||
selection.update_info();
|
||||
}
|
||||
|
|
@ -308,7 +397,15 @@ function platformSelect(widget, shift, recurse = true) {
|
|||
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;
|
||||
|
||||
if (!view.is_arrange()) {
|
||||
|
|
@ -320,7 +417,7 @@ function platformDeselect(widget, recurse = true) {
|
|||
// apply deselect to entire group
|
||||
if (recurse && widget && widget.group.length > 1) {
|
||||
for (let w of widget.group) {
|
||||
platformDeselect(w, false);
|
||||
deselect(w, false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -337,13 +434,20 @@ function platformDeselect(widget, recurse = true) {
|
|||
}
|
||||
|
||||
$(`ws-${widget.id}`)?.classList.remove('selected');
|
||||
widget.setColor(COLOR.deselected);
|
||||
const scheme = getColorScheme();
|
||||
widget.setColor(scheme.widget.deselected);
|
||||
platform.update_selected();
|
||||
selection.update_info();
|
||||
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) {
|
||||
platform.load_stl(url, onload);
|
||||
} 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) {
|
||||
let widget = newWidget().loadVertices(vertices);
|
||||
widget.meta.file = filename;
|
||||
|
|
@ -370,9 +482,16 @@ function platformLoadSTL(url, onload, formdata, 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();
|
||||
load.URL.load(url, options).then(objects => {
|
||||
file_load.URL.load(url, options).then(objects => {
|
||||
let widgets = [];
|
||||
for (let object of objects) {
|
||||
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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
Widget.Groups.loadDone();
|
||||
if (api.feature.drop_layout && !skipLayout) {
|
||||
|
|
@ -404,10 +532,24 @@ function platformGroupDone(skipLayout) {
|
|||
}
|
||||
}
|
||||
|
||||
/** Deferred widget additions pending batch processing */
|
||||
let deferred = [];
|
||||
|
||||
/** Timeout handle for deferred widget batch processing */
|
||||
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);
|
||||
space.world.add(widget.mesh);
|
||||
widget.anno = widget.anno || {};
|
||||
|
|
@ -420,13 +562,15 @@ function platformAdd(widget, shift, nolayout, defer) {
|
|||
platform.select(widget, shift);
|
||||
platform.update_bounds();
|
||||
api.space.auto_save();
|
||||
platformChanged();
|
||||
changed();
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
if (!grouping) {
|
||||
platformGroupDone(nolayout);
|
||||
group_done(nolayout);
|
||||
if (!current().controller.autoLayout) {
|
||||
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() {
|
||||
let skiplayout = false;
|
||||
for (let rec of deferred) {
|
||||
|
|
@ -445,15 +595,27 @@ function platformAddDeferred() {
|
|||
}
|
||||
}
|
||||
if (!grouping) {
|
||||
platformGroupDone(skiplayout);
|
||||
group_done(skiplayout);
|
||||
}
|
||||
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();
|
||||
api.space.auto_save();
|
||||
platformChanged();
|
||||
changed();
|
||||
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) {
|
||||
if (api.widgets.count() <= 1) {
|
||||
return;
|
||||
|
|
@ -470,8 +632,8 @@ function positionNewWidget(widget) {
|
|||
x: dim.x / 2,
|
||||
y: dim.y / 2
|
||||
};
|
||||
const bounds = base.newBounds();
|
||||
const target = base.newBounds();
|
||||
const bounds = newBounds();
|
||||
const target = newBounds();
|
||||
const DEG2RAD = Math.PI / 180;
|
||||
const WIDGETS = api.widgets.all();
|
||||
// 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) {
|
||||
if (!widget) {
|
||||
return;
|
||||
|
|
@ -511,7 +682,7 @@ function platformDelete(widget, defer) {
|
|||
for (let i = 0; i < mc.length; i++) {
|
||||
platform.delete(mc[i].widget || mc[i], true);
|
||||
}
|
||||
platformDeletePost();
|
||||
delete_post();
|
||||
api.event.emit('widget.delete', widget);
|
||||
return;
|
||||
}
|
||||
|
|
@ -521,12 +692,17 @@ function platformDelete(widget, defer) {
|
|||
Widget.Groups.remove(widget);
|
||||
space.world.remove(widget.mesh);
|
||||
if (!defer) {
|
||||
platformDeletePost();
|
||||
delete_post();
|
||||
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();
|
||||
platform.update_bounds();
|
||||
if (get_mode() !== MODES.FDM) {
|
||||
|
|
@ -538,11 +714,15 @@ function platformDeletePost() {
|
|||
platform.layout();
|
||||
}
|
||||
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 => {
|
||||
let color;
|
||||
let file = w.meta.file || 'no name';
|
||||
|
|
@ -588,8 +768,9 @@ function platformChanged() {
|
|||
onclick() {
|
||||
let dis = w.meta.disabled = !w.meta.disabled;
|
||||
let sel = api.selection.contains(w);
|
||||
const scheme = getColorScheme();
|
||||
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" }) ]
|
||||
|
|
@ -619,7 +800,7 @@ function platformChanged() {
|
|||
// w.setColor(color, null, false);
|
||||
// },
|
||||
onclick() {
|
||||
platformSelect(w, true, false);
|
||||
select(w, true, false);
|
||||
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 => {
|
||||
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 settings = current();
|
||||
const { process, device, controller } = settings;
|
||||
|
|
@ -659,7 +864,7 @@ function platformLayout() {
|
|||
gap = gap || ((process.sliceSupportExtra || 0) * 2) + 1;
|
||||
// auto resize device to support a larger object
|
||||
if (isBelt) {
|
||||
fitDeviceToWidgets();
|
||||
fit();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
@ -686,7 +891,7 @@ function platformLayout() {
|
|||
if (isBelt) {
|
||||
gap += process.firstLayerBeltLead || 0;
|
||||
const WIDGETS = api.widgets.all();
|
||||
let bounds = platformUpdateBounds(),
|
||||
let bounds = update_bounds(),
|
||||
movey = -(device.bedDepth / 2);
|
||||
for (let widget of WIDGETS) {
|
||||
let ylen = widget.track.box.h;
|
||||
|
|
@ -737,15 +942,32 @@ function platformLayout() {
|
|||
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);
|
||||
widget.meta.file = filename;
|
||||
if (filename) widget.saveToCatalog(filename);
|
||||
platformAdd(widget);
|
||||
add(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();
|
||||
let loading = files.length;
|
||||
for (let file of files) {
|
||||
|
|
@ -773,7 +995,7 @@ function platformLoadFiles(files, group) {
|
|||
}
|
||||
}
|
||||
if (israw) {
|
||||
platformLoadWidget(group, JSON.parse(data));
|
||||
load_verts(group, JSON.parse(data));
|
||||
load_dec();
|
||||
} else if (api.feature.on_load && (isstl || isobj || is3mf)) {
|
||||
api.feature.on_load(data, file);
|
||||
|
|
@ -782,14 +1004,14 @@ function platformLoadFiles(files, group) {
|
|||
api.feature.on_add_stl(data, file);
|
||||
load_dec();
|
||||
} else if (isstl) {
|
||||
const stl = new load.STL().parse(data, api.view.unit_scale());
|
||||
platformLoadWidget(group, stl, name);
|
||||
const stl = new file_load.STL().parse(data, api.view.unit_scale());
|
||||
load_verts(group, stl, name);
|
||||
load_dec();
|
||||
} 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() {
|
||||
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();
|
||||
};
|
||||
|
|
@ -810,7 +1032,7 @@ function platformLoadFiles(files, group) {
|
|||
api.show.progress(progress, "converting");
|
||||
}, vertices => {
|
||||
api.show.progress(0);
|
||||
let wid = platformLoadWidget(group, vertices, name);
|
||||
let wid = load_verts(group, vertices, name);
|
||||
if (api.mode.is_cam()) {
|
||||
// attach raw illustration
|
||||
// api.event.emit('cam.parse.gerber', { data: text, mesh: wid.mesh });
|
||||
|
|
@ -821,13 +1043,13 @@ function platformLoadFiles(files, group) {
|
|||
let odon = function(models) {
|
||||
let msg = api.show.alert('Adding Objects');
|
||||
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();
|
||||
api.hide.alert(msg);
|
||||
}
|
||||
let msg = api.show.alert('Decoding 3MF');
|
||||
load.TMF.parseAsync(data).then(models => {
|
||||
file_load.TMF.parseAsync(data).then(models => {
|
||||
api.hide.alert(msg);
|
||||
if (models.length > 1 && !group) {
|
||||
api.uc.confirm(`group ${models.length} objects?`).then(ok => {
|
||||
|
|
@ -846,14 +1068,14 @@ function platformLoadFiles(files, group) {
|
|||
} else if (issvg) {
|
||||
loadSVGDialog(opt => {
|
||||
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;
|
||||
if (svg.length === 0) {
|
||||
api.show.alert(`SVG contains no polylines`, 10);
|
||||
api.show.alert(`Fonts must be converted to paths`, 10);
|
||||
}
|
||||
for (let v of svg) {
|
||||
platformLoadWidget(group, svg[ind++], ind ? `${name}-${ind}` : name);
|
||||
load_verts(group, svg[ind++], ind ? `${name}-${ind}` : name);
|
||||
}
|
||||
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) {
|
||||
const opt = {pre: [
|
||||
"<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;
|
||||
api.widgets.each(widget => {
|
||||
let wb = widget.mesh.getBoundingBox().clone();
|
||||
|
|
@ -919,28 +1153,28 @@ function fitDeviceToWidgets() {
|
|||
|
||||
// extend API (api.platform)
|
||||
export const platform = {
|
||||
fit: fitDeviceToWidgets,
|
||||
add: platformAdd,
|
||||
changed: platformChanged,
|
||||
fit,
|
||||
add,
|
||||
changed,
|
||||
delete: platformDelete,
|
||||
layout: platformLayout,
|
||||
group: platformGroup,
|
||||
group_done: platformGroupDone,
|
||||
load: platformLoad,
|
||||
load_stl: platformLoadSTL,
|
||||
load_url: platformLoadURL,
|
||||
load_files: platformLoadFiles,
|
||||
load_verts: platformLoadWidget,
|
||||
deselect: platformDeselect,
|
||||
select: platformSelect,
|
||||
select_all: platformSelectAll,
|
||||
selected_count: platformSelectedCount,
|
||||
set_bounds: platformSetBounds,
|
||||
update_origin: platformUpdateOrigin,
|
||||
update_bounds: platformUpdateBounds,
|
||||
update_size: platformUpdateSize,
|
||||
update_top_z: platformUpdateTopZ,
|
||||
update_selected: platformUpdateSelected,
|
||||
layout: layout,
|
||||
group,
|
||||
group_done,
|
||||
load,
|
||||
load_stl,
|
||||
load_url,
|
||||
load_files,
|
||||
load_verts,
|
||||
deselect,
|
||||
select,
|
||||
select_all,
|
||||
selected_count,
|
||||
set_bounds,
|
||||
update_origin,
|
||||
update_bounds,
|
||||
update_size,
|
||||
update_top_z,
|
||||
update_selected,
|
||||
update: space.platform.update,
|
||||
set_font: space.platform.setFont,
|
||||
show_axes: space.platform.showAxes,
|
||||
129
src/kiri/app/preferences.js
Normal file
129
src/kiri/app/preferences.js
Normal 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
602
src/kiri/app/selected.js
Normal 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
309
src/kiri/app/slider.js
Normal 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();
|
||||
215
src/kiri/app/state/visual.js
Normal file
215
src/kiri/app/state/visual.js
Normal 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 };
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { local } from '../../data/local.js';
|
||||
import { broker } from '../../moto/broker.js';
|
||||
import { js2o, o2js } from './utils.js';
|
||||
import { js2o, o2js } from '../core/utils.js';
|
||||
|
||||
class Stats {
|
||||
constructor(db) {
|
||||
112
src/kiri/app/view-mode.js
Normal file
112
src/kiri/app/view-mode.js
Normal 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
390
src/kiri/app/visuals.js
Normal 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
404
src/kiri/app/widget.js
Normal 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 };
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
|
||||
|
||||
import { $ } from '../../moto/webui.js';
|
||||
import { api } from './api.js';
|
||||
import { api } from '../app/api.js';
|
||||
import { load } from '../../load/file.js';
|
||||
import { Widget, newWidget } from './widget.js';
|
||||
import { Widget, newWidget } from '../app/widget.js';
|
||||
|
||||
let WIDGETS = [];
|
||||
|
||||
|
|
@ -105,9 +105,9 @@ export const widgets = {
|
|||
replace,
|
||||
opacity(o) { setOpacity(o); console.trace('opacity() deprecated') },
|
||||
annotate,
|
||||
setIndexed,
|
||||
setAxisIndex,
|
||||
setColor(c,s) { api.widgets.each(widget => widget.setColor(c,undefined,s) )},
|
||||
setIndexed,
|
||||
setOpacity,
|
||||
all() { return WIDGETS.slice() },
|
||||
add(w) { return WIDGETS.push(w) },
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
|
||||
|
||||
import { api } from './api.js';
|
||||
import { noop } from './utils.js';
|
||||
import { codec } from './codec.js';
|
||||
import { noop } from '../core/utils.js';
|
||||
import { codec } from '../core/codec.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
|
||||
let debug = self.debug === true,
|
||||
|
|
@ -19,7 +21,7 @@ let debug = self.debug === true,
|
|||
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 {Function} onreply function to call on reply messages
|
||||
* @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
|
||||
running[seq] = { fn:onreply };
|
||||
}
|
||||
|
||||
let msg = {
|
||||
seq: seq,
|
||||
task: fn,
|
||||
time: time(),
|
||||
data: data
|
||||
};
|
||||
|
||||
// console.log('client send', msg);
|
||||
// if (!fn) { console.trace({empty_fn: data}) };
|
||||
|
||||
try {
|
||||
worker.postMessage(msg, zerocopy);
|
||||
} 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
|
||||
export const client = {
|
||||
send: send,
|
||||
send,
|
||||
|
||||
sendp,
|
||||
|
||||
setWorkPath(path) {
|
||||
workpath = path;
|
||||
|
|
@ -80,7 +94,7 @@ export const client = {
|
|||
const blob = new Blob([ work ], { type: 'application/javascript' });
|
||||
return new Worker(URL.createObjectURL(blob));
|
||||
} else {
|
||||
let worker = new Worker(workpath || api.const.PATHS.work, { type: 'module' });
|
||||
let worker = new Worker(workpath || WORKPATH, { type: 'module' });
|
||||
worker.onerror = (error) => {
|
||||
console.log({ WORKER_ERROR: error });
|
||||
error.preventDefault();
|
||||
|
|
@ -186,11 +200,12 @@ export const client = {
|
|||
let vertices = widget.getGeoVertices();
|
||||
send("sync", {
|
||||
id: widget.id,
|
||||
meta: widget.meta,
|
||||
anno: widget.annotations(),
|
||||
group: widget.group.id,
|
||||
meta: widget.meta,
|
||||
position: widget.mesh.position,
|
||||
track: widget.track,
|
||||
vertices,
|
||||
position: widget.mesh.position,
|
||||
}, done => {
|
||||
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
|
||||
slice(settings, widget, callback) {
|
||||
send("slice", {
|
||||
id: widget.id,
|
||||
anno: widget.annotations(),
|
||||
settings: settings
|
||||
}, reply => {
|
||||
callback(reply);
|
||||
|
|
@ -230,8 +251,8 @@ export const client = {
|
|||
|
||||
// called once after each widget is sliced
|
||||
// performs merged actions that consider all widget slice data
|
||||
sliceAll(settings, callback) {
|
||||
send("sliceAll", { settings }, callback);
|
||||
slicePost(settings, callback) {
|
||||
send("slicePost", { settings }, callback);
|
||||
},
|
||||
|
||||
// turn widget slices into unified path output
|
||||
|
|
@ -262,7 +283,7 @@ export const client = {
|
|||
ondone(null, reply.error);
|
||||
}
|
||||
if (reply.debug) {
|
||||
api.event.emit("export.debug", reply.debug);
|
||||
console.log('worker debug', reply.debug);
|
||||
}
|
||||
});
|
||||
},
|
||||
234
src/kiri/app/workspace.js
Normal file
234
src/kiri/app/workspace.js
Normal 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,
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -1,12 +1,13 @@
|
|||
/** 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 { Polygon, newPolygon } from '../../geo/polygon.js';
|
||||
import { Slice, newSlice, Top, newTop } from './slice.js';
|
||||
import { Widget, newWidget } from './widget.js';
|
||||
|
||||
const decoders = {};
|
||||
const factories = {};
|
||||
const freeMem = true;
|
||||
|
||||
const TYPE = {
|
||||
|
|
@ -213,6 +214,11 @@ function decodePointArray2D(array, z, fn) {
|
|||
return points;
|
||||
}
|
||||
|
||||
function setFactory(type, factory) {
|
||||
factories[type] = factory;
|
||||
return codec;
|
||||
}
|
||||
|
||||
// ----- Widget Codec -----
|
||||
|
||||
Widget.prototype.encode = function(state) {
|
||||
|
|
@ -236,7 +242,8 @@ registerDecoder(TYPE.WIDGET, function(v, state) {
|
|||
const id = v.id,
|
||||
group = v.group || id,
|
||||
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,
|
||||
anno = v.anno || widget.anno;
|
||||
|
||||
|
|
@ -480,6 +487,7 @@ registerDecoder(TYPE.LAYERS, function(v, state) {
|
|||
});
|
||||
|
||||
export const codec = {
|
||||
TYPE,
|
||||
encode,
|
||||
decode,
|
||||
registerDecoder,
|
||||
|
|
@ -488,10 +496,12 @@ export const codec = {
|
|||
decodePointArray,
|
||||
encodePointArray2D,
|
||||
decodePointArray2D,
|
||||
setFactory,
|
||||
toCodable
|
||||
};
|
||||
|
||||
export {
|
||||
TYPE,
|
||||
encode,
|
||||
decode,
|
||||
registerDecoder,
|
||||
|
|
@ -500,6 +510,7 @@ export {
|
|||
decodePointArray,
|
||||
encodePointArray2D,
|
||||
decodePointArray2D,
|
||||
setFactory,
|
||||
toCodable
|
||||
};
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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> 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> 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 };
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
|
||||
|
||||
import { arcToPath } from '../../geo/paths.js';
|
||||
import { consts } from './consts.js';
|
||||
import { createVM } from '../../moto/quickjs.js';
|
||||
import { newPoint } from '../../geo/point.js';
|
||||
import { newPolygon } from '../../geo/polygon.js';
|
||||
import { util } from '../../geo/base.js';
|
||||
|
||||
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;
|
||||
|
||||
class Print {
|
||||
|
|
@ -24,6 +24,7 @@ class Print {
|
|||
this.tools = {};
|
||||
// set to 1 to enable flow rate analysis (console)
|
||||
this.debugE = settings ? (settings.controller.devel ? 1 : 0) : 0;
|
||||
this._ready = this.createSafeEval();
|
||||
}
|
||||
|
||||
setType(type) {
|
||||
|
|
@ -40,7 +41,7 @@ class Print {
|
|||
* addOutput - add a new point to the output gcode array
|
||||
* @param {any[]} array - the output gcode array
|
||||
* @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 {string} tool - the tool id
|
||||
* @param {"lerp"|string} opts.type - the output type
|
||||
|
|
@ -50,7 +51,7 @@ class Print {
|
|||
* @return {Output} - the new output object
|
||||
*/
|
||||
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 arc = emit == 2 || emit == 3;
|
||||
// drop duplicates (usually intruced by FDM bisections)
|
||||
|
|
@ -67,7 +68,9 @@ class Print {
|
|||
this.lastOut = lastOut = new Output(point, emit, speed, tool, {
|
||||
type: type ?? this.nextType,
|
||||
center,
|
||||
clockwise,
|
||||
arcPoints,
|
||||
distance
|
||||
});
|
||||
if (tool !== undefined) {
|
||||
this.tools[tool] = true;
|
||||
|
|
@ -128,10 +131,9 @@ class Print {
|
|||
perimeter = poly.perimeter(),
|
||||
close = !options.open,
|
||||
scarf = !poly.open ? (options.scarf ?? 0) : false,
|
||||
tool = options.tool,
|
||||
zmax = options.zmax,
|
||||
last = startPoint,
|
||||
first = true;
|
||||
first = true,
|
||||
{ arcOpts, tool, zmax, onfirst, onfirstout } = options;
|
||||
|
||||
// if short, use calculated print speed based on sliding scale
|
||||
if (perimeter < process.outputShortPoly) {
|
||||
|
|
@ -167,7 +169,7 @@ class Print {
|
|||
if (scarf) {
|
||||
let epz = Math.max(...poly.points.map(p => p.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;
|
||||
let lp, sp = [];
|
||||
for (let p of pp) {
|
||||
|
|
@ -191,6 +193,10 @@ class Print {
|
|||
}
|
||||
pp.push(...esp);
|
||||
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
|
||||
|
|
@ -199,19 +205,44 @@ class Print {
|
|||
}
|
||||
|
||||
let lpo;
|
||||
let arcInfo;
|
||||
let arcDist = 0;
|
||||
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) {
|
||||
// basic thin wall skips are converted to moves
|
||||
scope.addOutput(output, point, 0, moveSpeed, tool);
|
||||
} else if (first) {
|
||||
// if (point.skip) console.log({ skip: point });
|
||||
if (options.onfirst) {
|
||||
options.onfirst(point, output);
|
||||
}
|
||||
onfirst && onfirst(point, output);
|
||||
// move to first output point on poly
|
||||
let out = scope.addOutput(output, point, 0, moveSpeed, tool);
|
||||
if (options.onfirstout) {
|
||||
options.onfirstout(out);
|
||||
}
|
||||
onfirstout && onfirstout(out);
|
||||
first = false;
|
||||
} else {
|
||||
let seglen = last.distTo2D(point);
|
||||
|
|
@ -237,7 +268,26 @@ class Print {
|
|||
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) {
|
||||
let safeEval = this.safeEval;
|
||||
if (safeEval) {
|
||||
safeEval.setContext(consts);
|
||||
}
|
||||
function tryeval(str) {
|
||||
try {
|
||||
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(`try {( ${tok} )} catch (e) {console.log(e);0}`);
|
||||
let evl = tryeval(eva.join(''));
|
||||
let evl = safeEval ? safeEval.eval(tok) : tryeval(eva.join(''));
|
||||
nutok = evl;
|
||||
if (pad === 666) {
|
||||
return evl;
|
||||
|
|
@ -538,8 +588,8 @@ class Print {
|
|||
let { point, prevPoint } = processLine(line, axes);
|
||||
|
||||
if (morph && belt) {
|
||||
point.y -= point.z * beltfact;
|
||||
point.z *= beltfact;
|
||||
point.y -= point.z * BELTFACT;
|
||||
point.z *= BELTFACT;
|
||||
}
|
||||
|
||||
const retract = (fdm && pos.E < 0) || undefined;
|
||||
|
|
@ -725,15 +775,16 @@ class Output {
|
|||
*/
|
||||
constructor(point, emit, speed, tool, options) {
|
||||
|
||||
const { type, center, arcPoints } = (options ?? {});
|
||||
//speed, tool, type, center, arcPoints
|
||||
const { type, center, arcPoints, clockwise, distance } = (options ?? {});
|
||||
|
||||
this.point = point;
|
||||
this.emit = Number(emit); //convert bools into 0/1
|
||||
this.speed = speed;
|
||||
this.tool = tool;
|
||||
this.type = type;
|
||||
this.center = center;
|
||||
this.arcPoints = arcPoints;
|
||||
this.clockwise = clockwise;
|
||||
this.distance = distance;
|
||||
// this.where = new Error().stack.split("\n");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/** 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 { newPolygon } from '../../geo/polygon.js';
|
||||
import { newSlopeFromAngle } from '../../geo/slope.js';
|
||||
|
|
|
|||
|
|
@ -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() },
|
||||
};
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
/** 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 { polygons as POLY } from '../../geo/polygons.js';
|
||||
|
||||
|
|
@ -123,9 +123,9 @@ class Slice {
|
|||
return slice ? POLY.fingerprintCompare(this.fingerprint(), slice.fingerprint()) : false;
|
||||
};
|
||||
|
||||
addTops(polys) {
|
||||
addTops(polys, opt) {
|
||||
polys.forEach(p => {
|
||||
this.addTop(p);
|
||||
this.addTop(p, opt);
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
|
@ -134,16 +134,23 @@ class Slice {
|
|||
* @param {Object | Polygon} data
|
||||
* @returns Slice.Top
|
||||
*/
|
||||
addTop(data) {
|
||||
addTop(data, opt = {}) {
|
||||
let minArea = opt.minArea ?? 0;
|
||||
if (data.length) {
|
||||
// standard legacy polygon
|
||||
let top = new Top(data);
|
||||
if (top.area() < minArea) {
|
||||
return null;
|
||||
}
|
||||
this.tops.push(top);
|
||||
top.simple = data;
|
||||
return top;
|
||||
} else {
|
||||
// create top object from object bundle passed back by slicePost()
|
||||
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_wall = data.thin_wall;
|
||||
top.thin_sort = data.thin_sort;
|
||||
|
|
@ -152,7 +159,7 @@ class Slice {
|
|||
top.fill_off = data.fill_off;
|
||||
top.last = data.last;
|
||||
top.gaps = data.gaps;
|
||||
top.shells = data.shells;
|
||||
top.shells = data.shells.filter(p => p.area() > minArea);
|
||||
top.simple = data.simple;
|
||||
this.tops.push(top);
|
||||
return top;
|
||||
|
|
@ -208,6 +215,10 @@ class Top {
|
|||
this.poly = polygon; // outline poly
|
||||
}
|
||||
|
||||
area() {
|
||||
return this.poly.area();
|
||||
}
|
||||
|
||||
clone(deep) {
|
||||
let top = new Top(this.poly.clone(deep));
|
||||
return top;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
|
||||
|
||||
import { base } from '../../geo/base.js';
|
||||
import { avgc } from './utils.js';
|
||||
import { decode } from '../core/codec.js';
|
||||
import { util as mesh_util } from '../../mesh/util.js';
|
||||
import { tool as mesh_tool } from '../../mesh/tool.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 { checkOverUnderOn, intersectPoints } from '../../geo/slicer.js';
|
||||
|
||||
const { inRange, time } = base.util;
|
||||
const solid_opacity = 1.0;
|
||||
const groups = [];
|
||||
|
||||
const sharedArrayClass = self.SharedArrayBuffer || undefined;
|
||||
const hasSharedArrays = sharedArrayClass ? true : false;
|
||||
const solid_opacity = 1.0;
|
||||
const groups = [];
|
||||
const debug_shadow = false;
|
||||
|
||||
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 {
|
||||
constructor(id, group) {
|
||||
this.api = self.kiri_api;
|
||||
|
||||
this.id = id || Date.now().toString(36)+(nextId++);
|
||||
this.grouped = group ? true : false;
|
||||
// persisted
|
||||
this.group = group || [];
|
||||
this.group.push(this);
|
||||
if (!this.group.id) {
|
||||
|
|
@ -41,10 +34,8 @@ class Widget {
|
|||
}
|
||||
// rotation stack (for undo)
|
||||
this.roto = [];
|
||||
// added meshes (supports, tabs, etc)
|
||||
// overlay meshes (supports, tabs, etc)
|
||||
this.adds = [];
|
||||
// persisted client annotations (cam tabs, fdm supports)
|
||||
this.anno = {};
|
||||
// THREE Mesh and points
|
||||
this.mesh = null;
|
||||
this.points = null;
|
||||
|
|
@ -56,6 +47,26 @@ class Widget {
|
|||
this.settings = null; // used??
|
||||
this.modified = 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 = {
|
||||
// box size for packer
|
||||
box: {
|
||||
|
|
@ -80,62 +91,9 @@ class Widget {
|
|||
},
|
||||
top: 0, // z top
|
||||
mirror: false,
|
||||
indexed: false
|
||||
},
|
||||
// used to cache shadow geo
|
||||
this.cache = {};
|
||||
this.stats = {
|
||||
slice_time: 0,
|
||||
load_time: 0,
|
||||
progress: 0
|
||||
indexed: false,
|
||||
indexRad: 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() {
|
||||
|
|
@ -231,9 +189,14 @@ class Widget {
|
|||
return this.loadVertices(...arguments);
|
||||
}
|
||||
|
||||
setModified() {
|
||||
setModified(reason) {
|
||||
this.modified = 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) {
|
||||
// this fixes ray intersections after the mesh is modified
|
||||
this.mesh.geometry.boundingSphere = null;
|
||||
|
|
@ -274,6 +237,8 @@ class Widget {
|
|||
]);
|
||||
mesh.renderOrder = 1;
|
||||
// geometry.computeVertexNormals();
|
||||
// Clear existing groups (e.g., from cloned geometry) before adding new one
|
||||
geometry.clearGroups();
|
||||
geometry.addGroup(0, Infinity, 0);
|
||||
// mesh.castShadow = true;
|
||||
// mesh.receiveShadow = true;
|
||||
|
|
@ -311,23 +276,6 @@ class Widget {
|
|||
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() {
|
||||
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) {
|
||||
const mat = this.getMaterial();
|
||||
|
|
@ -463,7 +370,7 @@ class Widget {
|
|||
// invalidate cached points
|
||||
if (x || y || z) {
|
||||
this.points = null;
|
||||
this.setModified();
|
||||
this.setModified('moveMesh');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -480,11 +387,11 @@ class Widget {
|
|||
}
|
||||
|
||||
setAxisIndex(deg) {
|
||||
// console.trace(deg);
|
||||
let rad = deg * (Math.PI / 180);
|
||||
if (rad !== this.track.indexRad) {
|
||||
this.track.indexRad = rad;
|
||||
this.clearShadows();
|
||||
this.setModified();
|
||||
this.setModified('axis');
|
||||
this._updateMeshPosition();
|
||||
}
|
||||
}
|
||||
|
|
@ -537,27 +444,35 @@ class Widget {
|
|||
pos.z += (z || 0);
|
||||
}
|
||||
if (x || y || z) {
|
||||
this.setModified();
|
||||
this.setModified('_move');
|
||||
this._updateMeshPosition();
|
||||
}
|
||||
}
|
||||
|
||||
_updateMeshPosition() {
|
||||
let mesh = this.mesh,
|
||||
track = this.track,
|
||||
tzoff = track.tzoff,
|
||||
top = track.top,
|
||||
tz = -tzoff,
|
||||
{ x, y, z } = track.pos;
|
||||
let { cache, mesh, track } = this,
|
||||
{ top, tzoff } = track,
|
||||
{ x, y, z } = track.pos,
|
||||
tz = -tzoff;
|
||||
if (track.indexed) {
|
||||
let rad = track.indexRad;
|
||||
this.mesh.rotation.x = -rad;
|
||||
this.mesh.rotation.x = -track.indexRad;
|
||||
z = top;
|
||||
} else {
|
||||
this.mesh.rotation.x = 0;
|
||||
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) {
|
||||
|
|
@ -574,16 +489,14 @@ class Widget {
|
|||
let mesh = this.mesh,
|
||||
scale = this.track.scale;
|
||||
this.bounds = null;
|
||||
this.setWireframe(false);
|
||||
if (this.setWireframe) this.setWireframe(false);
|
||||
this.clearSlices();
|
||||
mesh.geometry.applyMatrix4(new THREE.Matrix4().makeScale(x, y, z));
|
||||
if (this.outline) {
|
||||
this.setEdges(true);
|
||||
}
|
||||
this._updateEdges();
|
||||
scale.x *= (x || 1.0);
|
||||
scale.y *= (y || 1.0);
|
||||
scale.z *= (z || 1.0);
|
||||
this.setModified();
|
||||
this.setModified('_scale');
|
||||
}
|
||||
|
||||
rotate(x, y, z, temp, center = true) {
|
||||
|
|
@ -593,9 +506,7 @@ class Widget {
|
|||
if (center) {
|
||||
this.center(false);
|
||||
}
|
||||
if (this.outline) {
|
||||
this.setEdges(true);
|
||||
}
|
||||
this._updateEdges();
|
||||
if ((x || y || z) && this.api && this.api.event) {
|
||||
this.api.event.emit('widget.rotate', {widget: this, x, y, z});
|
||||
}
|
||||
|
|
@ -604,7 +515,7 @@ class Widget {
|
|||
_rotate(x, y, z, temp) {
|
||||
if (!temp) {
|
||||
this.bounds = null;
|
||||
this.setWireframe(false);
|
||||
if (this.setWireframe) this.setWireframe(false);
|
||||
this.clearSlices();
|
||||
}
|
||||
let m4 = new THREE.Matrix4();
|
||||
|
|
@ -622,7 +533,7 @@ class Widget {
|
|||
rot.y += (y || 0);
|
||||
rot.z += (z || 0);
|
||||
}
|
||||
this.setModified();
|
||||
this.setModified('_rotate');
|
||||
}
|
||||
|
||||
// undo all accumulated rotations
|
||||
|
|
@ -632,8 +543,8 @@ class Widget {
|
|||
});
|
||||
this.roto = [];
|
||||
this.center();
|
||||
this.setModified();
|
||||
this.refreshVisualState();
|
||||
this.setModified('unrotate');
|
||||
if (this.refreshVisualState) this.refreshVisualState();
|
||||
}
|
||||
|
||||
mirror() {
|
||||
|
|
@ -648,7 +559,7 @@ class Widget {
|
|||
|
||||
_mirror() {
|
||||
this.clearSlices();
|
||||
this.setWireframe(false);
|
||||
if (this.setWireframe) this.setWireframe(false);
|
||||
let geo = this.mesh.geometry, ot = this.track;
|
||||
let pos = geo.attributes.position;
|
||||
let arr = pos.array;
|
||||
|
|
@ -671,7 +582,7 @@ class Widget {
|
|||
}
|
||||
pos.needsUpdate = true;
|
||||
ot.mirror = !ot.mirror;
|
||||
this.setModified();
|
||||
this.setModified('_mirror');
|
||||
this.points = null;
|
||||
}
|
||||
|
||||
|
|
@ -694,6 +605,17 @@ class Widget {
|
|||
|
||||
getGeoVertices(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 pos = geo.getAttribute('position');
|
||||
// if indexed, return points rotated about X and then offset
|
||||
|
|
@ -703,6 +625,7 @@ class Widget {
|
|||
.applyMatrix4( new THREE.Matrix4().makeRotationX(-track.indexRad) );
|
||||
}
|
||||
pos = pos.array;
|
||||
// unroll indexed geometry
|
||||
if (geo.index && unroll !== false) {
|
||||
let idx = geo.index.array;
|
||||
let len = idx.length;
|
||||
|
|
@ -716,12 +639,8 @@ class Widget {
|
|||
pp2[inc++] = pos[ip];
|
||||
}
|
||||
pos = pp2;
|
||||
} else if (translate) {
|
||||
pos = pos.slice();
|
||||
}
|
||||
// used by CAM.shadowAt()
|
||||
this.cache.geo = pos;
|
||||
delete this.cache.shadow;
|
||||
geoCache[cacheKey] = { pos, marked };
|
||||
return pos;
|
||||
}
|
||||
|
||||
|
|
@ -739,7 +658,7 @@ class Widget {
|
|||
if (!this.bounds || refresh || this.boundingBoxNeedsUpdate) {
|
||||
this.bounds = new THREE.Box3().setFromArray(this.getGeoVertices({
|
||||
translate: true,
|
||||
unroll: false
|
||||
unroll: true
|
||||
}));
|
||||
this.boundingBoxNeedsUpdate = false;
|
||||
}
|
||||
|
|
@ -782,65 +701,6 @@ class Widget {
|
|||
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() {
|
||||
this.mesh.visible = true;
|
||||
|
|
@ -856,7 +716,12 @@ class Widget {
|
|||
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;
|
||||
if (!shadows) {
|
||||
shadows = this.cache.shadows = {};
|
||||
|
|
@ -868,20 +733,34 @@ class Widget {
|
|||
// 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 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) {
|
||||
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);
|
||||
}
|
||||
|
||||
// create a stack of faces in 1mm increments
|
||||
// the stacks are then used to produce shadowlines
|
||||
#ensureShadowCache() {
|
||||
#ensureShadowCache(pocket) {
|
||||
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 bounds = this.getBoundingBox();
|
||||
const stack = {};
|
||||
|
|
@ -893,7 +772,8 @@ class Widget {
|
|||
const b = 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);
|
||||
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;
|
||||
}
|
||||
const minZ = Math.floor(Math.min(a.z, b.z, c.z));
|
||||
|
|
@ -902,15 +782,55 @@ class Widget {
|
|||
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)
|
||||
// slice the triangle stack matchingg z then union the results
|
||||
#computeShadowAt(z, ztop) {
|
||||
this.#ensureShadowCache();
|
||||
#computeShadowAt(z, ztop, cached, pocket) {
|
||||
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 stack = this.cache.shadow;
|
||||
const stack = cached ?? this.#ensureShadowCache(pocket);
|
||||
let minZ = Math.floor(z);
|
||||
let maxZ = Math.ceil(z);
|
||||
let slices = [];
|
||||
|
|
@ -965,84 +885,18 @@ class Widget {
|
|||
});
|
||||
|
||||
// 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
|
||||
// todo: create clipper only version that avoids round trip thru geo classes
|
||||
polys = POLY.offset(polys, 0.01);
|
||||
polys = POLY.offset(polys, -0.01);
|
||||
|
||||
if (debug_shadow) console.timeEnd(label);
|
||||
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
|
||||
const Group = Widget.Groups = {
|
||||
list() {
|
||||
|
|
@ -1143,40 +997,8 @@ const Group = Widget.Groups = {
|
|||
}
|
||||
};
|
||||
|
||||
Widget.loadFromCatalog = function(filename, ondone) {
|
||||
catalog().getFile(filename, function(data) {
|
||||
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);
|
||||
};
|
||||
function newWidget(id,group) {
|
||||
return new Widget(id,group);
|
||||
}
|
||||
|
||||
export { Widget, newWidget };
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
400
src/kiri/dev/fdm/Snapmaker.U1.json
Normal file
400
src/kiri/dev/fdm/Snapmaker.U1.json
Normal 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 <-----------------------<"
|
||||
]
|
||||
}
|
||||
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
// UI FRONT END ANIMATION CODE for 2D
|
||||
|
||||
import { api } from '../../core/api.js';
|
||||
import { space } from '../../../moto/space.js';
|
||||
import { api } from '../../../app/api.js';
|
||||
import { space } from '../../../../moto/space.js';
|
||||
|
||||
const { client } = api;
|
||||
|
||||
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
// UI FRONT END ANIMATION CODE for 3D (indexed)
|
||||
|
||||
import { api } from '../../core/api.js';
|
||||
import { space } from '../../../moto/space.js';
|
||||
import { api } from '../../../app/api.js';
|
||||
import { space } from '../../../../moto/space.js';
|
||||
|
||||
const { client } = api;
|
||||
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
|
||||
|
||||
import { api } from '../../core/api.js';
|
||||
import { env, opAdd, opRender } from './client.js';
|
||||
import { CAM } from './driver-fe.js';
|
||||
import { api } from '../../../app/api.js';
|
||||
import { env, opAdd, opRender } from './init-ui.js';
|
||||
import { CAM } from './dispatch.js';
|
||||
import { clearTabs, restoreTabs } from './cl-tab.js';
|
||||
import { updateStock } from './cl-stock.js';
|
||||
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { api } from "../../core/api.js";
|
||||
import { CAM } from "./driver-fe.js";
|
||||
import { clearPops, env } from "./client.js";
|
||||
import { api } from '../../../app/api.js';
|
||||
import { CAM } from "./dispatch.js";
|
||||
import { clearPops, env } from "./init-ui.js";
|
||||
|
||||
let alert, lastWidget;
|
||||
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
|
||||
|
||||
import { api } from '../../core/api.js';
|
||||
import { env, clearPops } from './client.js';
|
||||
import { CAM } from './driver-fe.js';
|
||||
import { api } from '../../../app/api.js';
|
||||
import { env, clearPops } from './init-ui.js';
|
||||
import { CAM } from './dispatch.js';
|
||||
|
||||
export let holeSelOn = false;
|
||||
export let lastSelHoles;
|
||||
|
|
@ -1,14 +1,16 @@
|
|||
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
|
||||
|
||||
import { $, h } from '../../../moto/webui.js';
|
||||
import { api } from '../../core/api.js';
|
||||
import { env, opRender } from './client.js';
|
||||
import { Tool } from './tool.js';
|
||||
import { $, h } from '../../../../moto/webui.js';
|
||||
import { api } from '../../../app/api.js';
|
||||
import { env, opRender } from './init-ui.js';
|
||||
import { Tool } from '../core/tool.js';
|
||||
import { opFlip } from './cl-flip.js';
|
||||
import { selectHoles } from './cl-hole.js';
|
||||
import { selectHelical } from './cl-helical.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 { uc: UC } = api;
|
||||
|
|
@ -27,6 +29,10 @@ function hasSpindle() {
|
|||
return env.current.device.spindleMax > 0;
|
||||
}
|
||||
|
||||
function isWebGPU() {
|
||||
return api.conf.get().controller.webGPU;
|
||||
}
|
||||
|
||||
function zTop() {
|
||||
return api.conf.get().process.camZTop > 0;
|
||||
}
|
||||
|
|
@ -131,10 +137,21 @@ export function createPopOp(type, map) {
|
|||
return rec;
|
||||
},
|
||||
hideshow: () => {
|
||||
let conf = api.conf.get();
|
||||
for (let inp of Object.values(op.inputs)) {
|
||||
let parent = inp.parentElement;
|
||||
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() {
|
||||
|
||||
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', {
|
||||
tool: 'camLevelTool',
|
||||
spindle: 'camLevelSpindle',
|
||||
|
|
@ -236,10 +329,6 @@ export function createPopOps() {
|
|||
tool: UC.newSelect(LANG.cc_tool, {}, "tools"),
|
||||
direction: UC.newSelect(LANG.ou_dire_s, { title: LANG.ou_dire_l }, "direction"),
|
||||
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) }),
|
||||
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 }),
|
||||
|
|
@ -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 }),
|
||||
omitthru: UC.newBoolean(LANG.co_omit_s, undefined, { title: LANG.co_omit_l }),
|
||||
sep: UC.newBlank({ class: "pop-sep" }),
|
||||
exp: UC.newExpand("overrides"),
|
||||
ov_topz: UC.newInput(LANG.ou_ztop_s, { title: LANG.ou_ztop_l, convert: toFloat, units }),
|
||||
ov_botz: UC.newInput(LANG.ou_zbot_s, { title: LANG.ou_zbot_l, convert: toFloat, units }),
|
||||
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),
|
||||
ov_topz: UC.newInput(LANG.ou_ztop_s, ov_topz),
|
||||
ov_botz: UC.newInput(LANG.ou_zbot_s, ov_botz),
|
||||
exp_end: UC.endExpand(),
|
||||
};
|
||||
|
||||
|
|
@ -278,9 +372,6 @@ export function createPopOps() {
|
|||
tool: UC.newSelect(LANG.cc_tool, {}, "tools"),
|
||||
direction: UC.newSelect(LANG.ou_dire_s, { title: LANG.ou_dire_l }, "direction"),
|
||||
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 }),
|
||||
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 }),
|
||||
|
|
@ -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 } }),
|
||||
revbones: UC.newBoolean(LANG.co_dogr_s, undefined, { title: LANG.co_dogr_l, show: () => env.poppedRec.dogbones }),
|
||||
sep: UC.newBlank({ class: "pop-sep" }),
|
||||
exp: UC.newExpand("overrides"),
|
||||
ov_topz: UC.newInput(LANG.ou_ztop_s, { title: LANG.ou_ztop_l, convert: toFloat, units }),
|
||||
ov_botz: UC.newInput(LANG.ou_zbot_s, { title: LANG.ou_zbot_l, convert: toFloat, units }),
|
||||
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),
|
||||
ov_topz: UC.newInput(LANG.ou_ztop_s, ov_topz),
|
||||
ov_botz: UC.newInput(LANG.ou_zbot_s, ov_botz),
|
||||
exp_end: UC.endExpand(),
|
||||
};
|
||||
|
||||
|
|
@ -316,6 +412,7 @@ export function createPopOps() {
|
|||
bottom: 'camContourBottom',
|
||||
curves: 'camContourCurves',
|
||||
inside: 'camContourIn',
|
||||
clipto: 'camStockClipTo',
|
||||
filter: 'camContourFilter',
|
||||
axis: 'X'
|
||||
}).inputs = {
|
||||
|
|
@ -337,14 +434,17 @@ export function createPopOps() {
|
|||
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 }),
|
||||
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" })
|
||||
};
|
||||
|
||||
createPopOp('lathe', {
|
||||
tool: 'camLatheTool',
|
||||
spindle: 'camLatheSpindle',
|
||||
step: 'camLatheOver',
|
||||
angle: 'camLatheAngle',
|
||||
step: 'camLatheOver',
|
||||
down: 'camLatheDown',
|
||||
expand: 'camLatheExpand',
|
||||
rate: 'camLatheSpeed',
|
||||
tolerance: 'camTolerance',
|
||||
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 }),
|
||||
rate: UC.newInput(LANG.cc_feed_s, { title: LANG.cc_feed_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, 100.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" }),
|
||||
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}),
|
||||
|
|
@ -370,19 +473,6 @@ export function createPopOps() {
|
|||
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', {
|
||||
mode: 'camTraceType',
|
||||
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"),
|
||||
direction: UC.newSelect(LANG.ou_dire_s, { title: LANG.ou_dire_l }, "direction"),
|
||||
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" }),
|
||||
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" }),
|
||||
|
|
@ -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 }),
|
||||
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 }),
|
||||
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" }),
|
||||
ov_topz: UC.newInput(LANG.ou_ztop_s, { title: LANG.ou_ztop_l, convert: toFloat, units }),
|
||||
ov_botz: UC.newInput(LANG.ou_zbot_s, { title: LANG.ou_zbot_l, convert: toFloat, units }),
|
||||
ov_topz: UC.newInput(LANG.ou_ztop_s, ov_topz),
|
||||
ov_botz: UC.newInput(LANG.ou_zbot_s, ov_botz),
|
||||
exp_end: UC.endExpand(),
|
||||
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', {
|
||||
|
|
@ -450,10 +545,6 @@ export function createPopOps() {
|
|||
tool: UC.newSelect(LANG.cc_tool, {}, "tools"),
|
||||
direction: UC.newSelect(LANG.ou_dire_s, { title: LANG.ou_dire_l }, "direction"),
|
||||
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) }),
|
||||
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" }),
|
||||
|
|
@ -465,10 +556,15 @@ export function createPopOps() {
|
|||
sep: UC.newBlank({ class: "pop-sep" }),
|
||||
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 }),
|
||||
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" }),
|
||||
ov_topz: UC.newInput(LANG.ou_ztop_s, { title: LANG.ou_ztop_l, convert: toFloat, units }),
|
||||
ov_botz: UC.newInput(LANG.ou_zbot_s, { title: LANG.ou_zbot_l, convert: toFloat, units }),
|
||||
ov_topz: UC.newInput(LANG.ou_ztop_s, ov_topz),
|
||||
ov_botz: UC.newInput(LANG.ou_zbot_s, ov_botz),
|
||||
exp_end: UC.endExpand(),
|
||||
sep: UC.newBlank({ class: "pop-sep" }),
|
||||
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" })
|
||||
};
|
||||
|
||||
function angleTowardZUp() {
|
||||
api.event.emit('tool.mesh.face-up');
|
||||
}
|
||||
|
||||
createPopOp('index', {
|
||||
degrees: 'camIndexAxis',
|
||||
absolute: 'camIndexAbs'
|
||||
}).inputs = {
|
||||
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 }),
|
||||
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');
|
||||
|
|
@ -649,23 +741,13 @@ export function createPopOps() {
|
|||
|
||||
createPopOp('|', {}).inputs = {};
|
||||
|
||||
function isClear() {
|
||||
return env.poppedRec.mode === 'clear';
|
||||
}
|
||||
|
||||
function isTrace() {
|
||||
return env.poppedRec.mode === 'trace';
|
||||
}
|
||||
|
||||
function isSurface() {
|
||||
return env.poppedRec.mode === 'surface';
|
||||
}
|
||||
|
||||
function isSurfaceLinear() {
|
||||
return env.poppedRec.mode === 'surface' && env.poppedRec.sr_type === 'linear';
|
||||
}
|
||||
|
||||
const open = true;
|
||||
createPopOp('loop', {
|
||||
repeat: 2,
|
||||
count: 1
|
||||
}).inputs = {
|
||||
repeat: UC.newInput('repeat', { title: "repeat count", convert: toInt, bound: UC.bound(1, 100), post: opRender }),
|
||||
count: UC.newInput('operations', { title: "num operations", convert: toInt, bound: UC.bound(1, 20), post: opRender })
|
||||
};
|
||||
|
||||
createPopOp('area', {
|
||||
spindle: 'camAreaSpindle',
|
||||
|
|
@ -675,15 +757,18 @@ export function createPopOps() {
|
|||
tr_type: 'camAreaTrace',
|
||||
sr_type: 'camAreaSurface',
|
||||
sr_angle: 'camAreaAngle',
|
||||
sr_alter: 'camAreaZigZag',
|
||||
over: 'camAreaOver',
|
||||
down: 'camAreaDown',
|
||||
rate: 'camAreaSpeed',
|
||||
plunge: 'camAreaPlunge',
|
||||
expand: 'camAreaExpand',
|
||||
walls: 'camAreaWalls',
|
||||
smooth: 'camAreaSmooth',
|
||||
follow: 'camAreaFollow',
|
||||
refine: 'camAreaRefine',
|
||||
outline: 'camAreaOutline',
|
||||
shadow: 'camAreaShadow',
|
||||
tolerance: 'camTolerance',
|
||||
dogbones: 'camAreaDogbones',
|
||||
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"),
|
||||
sr_type: UC.newSelect("pattern", { title: "pattern", show: isSurface }, "surftyp"),
|
||||
sep: UC.newBlank({ class: "pop-sep" }),
|
||||
exp: UC.newExpand("area selection", { open }),
|
||||
menu: UC.newRow([
|
||||
UC.newButton("edge", traceAdd),
|
||||
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 }),
|
||||
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 }),
|
||||
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 }),
|
||||
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 }),
|
||||
tool: UC.newSelect(LANG.cc_tool, {}, "tools"),
|
||||
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() }),
|
||||
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 }),
|
||||
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 }),
|
||||
revbones: UC.newBoolean(LANG.co_dogr_s, undefined, { title: LANG.co_dogr_l, show: () => env.poppedRec.dogbones }),
|
||||
exp_end: UC.endExpand(),
|
||||
exp: UC.newExpand("feeds & speeds", { open }),
|
||||
exp: UC.newExpand("feeds & speeds", { }),
|
||||
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 }),
|
||||
exp_end: UC.endExpand(),
|
||||
exp: UC.newExpand("bounds", { open }),
|
||||
exp: UC.newExpand("bounds", ov_hover),
|
||||
sep: UC.newBlank({ class: "pop-sep" }),
|
||||
ov_topz: UC.newInput(LANG.ou_ztop_s, { title: LANG.ou_ztop_l, convert: toFloat, units }),
|
||||
ov_botz: UC.newInput(LANG.ou_zbot_s, { title: LANG.ou_zbot_l, convert: toFloat, units }),
|
||||
ov_topz: UC.newInput(LANG.ou_ztop_s, ov_topz),
|
||||
ov_botz: UC.newInput(LANG.ou_zbot_s, ov_botz),
|
||||
exp_end: UC.endExpand(),
|
||||
};
|
||||
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
|
||||
|
||||
import { api } from '../../core/api.js';
|
||||
import { env, clearPops } from './client.js';
|
||||
import { CAM } from './driver-fe.js';
|
||||
import { addbox, clearboxes } from '../../core/boxes.js';
|
||||
import { api } from '../../../app/api.js';
|
||||
import { env, clearPops } from './init-ui.js';
|
||||
import { CAM } from './dispatch.js';
|
||||
import { addbox, clearboxes } from '../../../app/boxes.js';
|
||||
|
||||
export let originSelectOn = false;
|
||||
|
||||
let { world } = api.SPACE;
|
||||
let { world } = api.const.SPACE;
|
||||
let alert, lastHover, lastPoints;
|
||||
|
||||
export function originSelect() {
|
||||
|
|
@ -1,13 +1,77 @@
|
|||
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
|
||||
|
||||
import { api } from '../../core/api.js';
|
||||
import { env, isDark } from './client.js';
|
||||
import { space as SPACE } from '../../../moto/space.js';
|
||||
import { api } from '../../../app/api.js';
|
||||
import { env, isDark } from './init-ui.js';
|
||||
import { space as SPACE } from '../../../../moto/space.js';
|
||||
|
||||
const { ui: UI } = api;
|
||||
|
||||
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() {
|
||||
if (env.isAnimate) {
|
||||
if (env.isIndexed) {
|
||||
|
|
@ -22,16 +86,12 @@ export function updateStock() {
|
|||
SPACE.world.remove(meshZBottom);
|
||||
SPACE.world.remove(env.camStock);
|
||||
env.camStock = null;
|
||||
meshZTop = null;
|
||||
meshZBottom = null;
|
||||
return;
|
||||
}
|
||||
|
||||
api.platform.update_bounds();
|
||||
|
||||
const settings = api.conf.get();
|
||||
const widgets = api.widgets.all();
|
||||
|
||||
const { stock, process } = settings;
|
||||
const { x, y, z, center } = stock;
|
||||
|
||||
|
|
@ -86,57 +146,8 @@ export function updateStock() {
|
|||
lines.material.color = new THREE.Color(isDark() ? 0x555555 : 0xaaaaaa);
|
||||
}
|
||||
|
||||
SPACE.world.remove(meshZTop);
|
||||
if (process.camZTop && 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
|
||||
});
|
||||
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;
|
||||
}
|
||||
showZTop(process.camZTop);
|
||||
showZBottom(process.camZBottom);
|
||||
|
||||
SPACE.update();
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
|
||||
|
||||
import { api } from '../../core/api.js';
|
||||
import { env, clearPops } from './client.js';
|
||||
import { CAM } from './driver-fe.js';
|
||||
import { api } from '../../../app/api.js';
|
||||
import { env, clearPops } from './init-ui.js';
|
||||
import { CAM } from './dispatch.js';
|
||||
|
||||
const DEG2RAD = Math.PI / 180;
|
||||
const RAD2DEG = 180 / Math.PI;
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
|
||||
|
||||
import { api } from '../../core/api.js';
|
||||
import { env, isDark } from './client.js';
|
||||
import { addbox, delbox, clearboxes } from '../../core/boxes.js';
|
||||
import { api } from '../../../app/api.js';
|
||||
import { colorSchemeRegistry } from '../../../app/color/schemes.js';
|
||||
import { env, isDark } from './init-ui.js';
|
||||
import { addbox, delbox, clearboxes } from '../../../app/boxes.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;
|
||||
|
||||
|
|
@ -225,9 +226,15 @@ export function tabClear() {
|
|||
}
|
||||
|
||||
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() {
|
||||
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);
|
||||
}
|
||||
230
src/kiri/mode/cam/app/cl-trace.js
Normal file
230
src/kiri/mode/cam/app/cl-trace.js
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
41
src/kiri/mode/cam/app/cl-zplane.js
Normal file
41
src/kiri/mode/cam/app/cl-zplane.js
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
|
||||
|
||||
import { api } from '../../core/api.js';
|
||||
import { codec } from '../../core/codec.js';
|
||||
import { api } from '../../../app/api.js';
|
||||
import { codec } from '../../../core/codec.js';
|
||||
|
||||
function surface_prep(index, ondone) {
|
||||
api.client.sync();
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
/** 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 { updateTool } from './tools.js';
|
||||
|
||||
|
|
@ -30,6 +30,30 @@ function zAnchorSave() {
|
|||
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() {
|
||||
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}),
|
||||
separator: newBlank({ class:"set-sep", driven }),
|
||||
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}),
|
||||
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 }),
|
||||
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}),
|
||||
camZTop: newInput(LANG.ou_ztop_s, {title:LANG.ou_ztop_l, convert:toFloat, units, trigger}),
|
||||
camZBottom: newInput(LANG.ou_zbot_s, {title:LANG.ou_zbot_l, convert:toFloat, units, trigger}),
|
||||
// camZThru: newInput(LANG.ou_ztru_s, {title:LANG.ou_ztru_l, convert:toFloat, bound:bound(0.0,100), units }),
|
||||
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, selector, bottom }),
|
||||
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 }),
|
||||
camFastFeed: newInput(LANG.cc_rapd_s, {title:LANG.cc_rapd_l, convert:toFloat, units}),
|
||||
|
|
@ -1,24 +1,25 @@
|
|||
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
|
||||
|
||||
import { $ } from '../../../moto/webui.js';
|
||||
import { api } from '../../core/api.js';
|
||||
import { load } from '../../../load/file.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-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 { $ } from '../../../../moto/webui.js';
|
||||
import { api } from '../../../app/api.js';
|
||||
import { animate as anim_2d, animate_clear as anim_2d_clear } from './anim-2d.js';
|
||||
import { animate2 as anim_3d, animate_clear2 as anim_3d_clear } from './anim-3d.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 { traceOn, traceDone, unselectTraces } from './cl-trace.js';
|
||||
import { holeSelOn, selectHolesDone, clearHolesRec } from './cl-hole.js';
|
||||
import { surfaceOn, surfaceDone } from './cl-surface.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';
|
||||
import { Widget, newWidget } from '../../../app/widget.js';
|
||||
import { zPlaneStart, zPlaneDone, zPlaneSelecting } from './cl-zplane.js';
|
||||
|
||||
const { BufferGeometryUtils } = THREE;
|
||||
|
||||
|
|
@ -62,13 +63,28 @@ export function isDark() {
|
|||
|
||||
export function clearPops() {
|
||||
if (env.func.unpop) env.func.unpop();
|
||||
helicalDone();
|
||||
originSelectDone();
|
||||
selectHolesDone();
|
||||
surfaceDone();
|
||||
tabDone();
|
||||
traceDone();
|
||||
selectHolesDone();
|
||||
helicalDone();
|
||||
zPlaneDone();
|
||||
// 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() {
|
||||
|
|
@ -95,7 +111,8 @@ function updateIndex() {
|
|||
if (op.absolute) {
|
||||
index = op.degrees
|
||||
} 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' : '';
|
||||
$('cam-index').style.display = showIndexed;
|
||||
$('cam-lathe').style.display = showIndexed;
|
||||
$('cam-loop').style.display = showIndexed;
|
||||
$('cam-flip').style.display = showNonIndexed;
|
||||
$('cam-reg').style.display = showNonIndexed;
|
||||
if (!changed) {
|
||||
|
|
@ -165,6 +183,10 @@ const opAddLevel = () => {
|
|||
opAdd(env.popOp.level.new());
|
||||
};
|
||||
|
||||
const opAddLoop = () => {
|
||||
opAdd(env.popOp.loop.new());
|
||||
};
|
||||
|
||||
const opAddRough = () => {
|
||||
opAdd(env.popOp.rough.new());
|
||||
};
|
||||
|
|
@ -282,13 +304,29 @@ export function opRender() {
|
|||
let mark = Date.now();
|
||||
let html = [];
|
||||
let bind = {};
|
||||
let scale = api.view.unit_scale();
|
||||
let notime = false;
|
||||
let inloop = 0;
|
||||
let numloop;
|
||||
oplist.forEach((rec, i) => {
|
||||
let title = '';
|
||||
let clock = rec.type === '|';
|
||||
let isLoop = rec.type === 'loop';
|
||||
let label = clock ? `` : (rec.mode ? rec.type + ' ' + rec.mode : rec.type);
|
||||
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;
|
||||
if (clock) { clazz.push('clock'); title = ` title="end of ops chain\ndrag/drop like an op\nops after this are disabled"` }
|
||||
if (notable?.length) {
|
||||
|
|
@ -333,6 +371,9 @@ export function opRender() {
|
|||
if (!env.isIndexed && type === 'lathe') {
|
||||
rec.disabled = true;
|
||||
}
|
||||
if (!env.isIndexed && (type === 'loop' || rec._numloop)) {
|
||||
rec.disabled = true;
|
||||
}
|
||||
if (!hasSharedArrays && (type === 'contour' || type === 'lathe')) {
|
||||
rec.disabled = true;
|
||||
}
|
||||
|
|
@ -346,7 +387,8 @@ export function opRender() {
|
|||
let popped = false;
|
||||
let poprec = env.popOp[rec.type];
|
||||
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.unpop = () => {
|
||||
|
|
@ -529,10 +571,29 @@ export function opRender() {
|
|||
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() {
|
||||
|
||||
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.absolute = true;
|
||||
opRender();
|
||||
|
|
@ -747,14 +808,13 @@ export function init() {
|
|||
};
|
||||
|
||||
$('op-add-list').onclick = (ev) => {
|
||||
let settings = api.conf.get();
|
||||
let { process, device } = settings;
|
||||
switch (ev.target.innerText.toLowerCase()) {
|
||||
case "index": return opAddIndex();
|
||||
case "laser on": return opAddLaserOn();
|
||||
case "laser off": return opAddLaserOff();
|
||||
case "gcode": return opAddGCode();
|
||||
case "level": return opAddLevel();
|
||||
case "loop": return opAddLoop();
|
||||
case "rough": return opAddRough();
|
||||
case "outline": return opAddOutline();
|
||||
case "contour":
|
||||
|
|
@ -792,8 +852,24 @@ export function init() {
|
|||
|
||||
// TAB/TRACE BUTTON HANDLERS
|
||||
api.event.on("button.click", target => {
|
||||
let process = api.conf.get().process;
|
||||
let { process, stock } = api.conf.get();
|
||||
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:
|
||||
return tabAdd();
|
||||
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.tabs.add", tabAdd);
|
||||
|
||||
api.event.on("cam.tabs.done", tabDone);
|
||||
|
||||
api.event.on("cam.tabs.clear", tabClear);
|
||||
api.event
|
||||
.on("cam.zplane.show", zPlaneShow)
|
||||
.on("cam.zplane.hide", zPlaneHide)
|
||||
.on("cam.zplane.select", zPlaneSelect);
|
||||
|
||||
// COMMON TAB/TRACE EVENT HANDLERS
|
||||
api.event.on("slice.begin", () => {
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue