diff --git a/.gitignore b/.gitignore index 7abc853e..2c523bd1 100644 --- a/.gitignore +++ b/.gitignore @@ -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* diff --git a/app.js b/app.js index bf380607..fb6f1459 100644 --- a/app.js +++ b/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); } diff --git a/bin/webpack-quickjs-bundle.js b/bin/webpack-quickjs-bundle.js new file mode 100644 index 00000000..7ec6d58b --- /dev/null +++ b/bin/webpack-quickjs-bundle.js @@ -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; diff --git a/bin/webpack-quickjs-esm.js b/bin/webpack-quickjs-esm.js new file mode 100644 index 00000000..4012a5ad --- /dev/null +++ b/bin/webpack-quickjs-esm.js @@ -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, + }, +}; diff --git a/docs/kiri-moto/controls.md b/docs/kiri-moto/controls.md index 517f84ac..5f07edc8 100644 --- a/docs/kiri-moto/controls.md +++ b/docs/kiri-moto/controls.md @@ -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 | diff --git a/mods/bambu/bambu.js b/mods/bambu/bambu.js index 76b43dfa..e27bf4e6 100644 --- a/mods/bambu/bambu.js +++ b/mods/bambu/bambu.js @@ -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'); diff --git a/mods/devel/kiri.js b/mods/devel/kiri.js index a4562540..514a25bb 100644 --- a/mods/devel/kiri.js +++ b/mods/devel/kiri.js @@ -20,4 +20,4 @@ self.kiri.load(api => { } }; }); -}); +}, 'Developer'); diff --git a/package.json b/package.json index facdcf06..e269f823 100644 --- a/package.json +++ b/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 ", "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" diff --git a/src/geo/base.js b/src/geo/base.js index 5bc1b0d1..6cfe986d 100644 --- a/src/geo/base.js +++ b/src/geo/base.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, diff --git a/src/geo/paths.js b/src/geo/paths.js index 139759f7..1e72369b 100644 --- a/src/geo/paths.js +++ b/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 } diff --git a/src/geo/point.js b/src/geo/point.js index f922b06f..04e78ff7 100644 --- a/src/geo/point.js +++ b/src/geo/point.js @@ -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; } } diff --git a/src/geo/poly/arcs.js b/src/geo/poly/arcs.js new file mode 100644 index 00000000..2c67efb5 --- /dev/null +++ b/src/geo/poly/arcs.js @@ -0,0 +1,326 @@ +/** Copyright Stewart Allen -- 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= 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; +} diff --git a/src/geo/poly/geo.js b/src/geo/poly/geo.js new file mode 100644 index 00000000..0a69a951 --- /dev/null +++ b/src/geo/poly/geo.js @@ -0,0 +1,280 @@ +/** Copyright Stewart Allen -- 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; +} diff --git a/src/geo/poly/mesh.js b/src/geo/poly/mesh.js new file mode 100644 index 00000000..3a2768bb --- /dev/null +++ b/src/geo/poly/mesh.js @@ -0,0 +1,275 @@ +/** Copyright Stewart Allen -- 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 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 -- 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 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 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; +} diff --git a/src/geo/polygon.js b/src/geo/polygon.js index 1349371f..634b2984 100644 --- a/src/geo/polygon.js +++ b/src/geo/polygon.js @@ -2,13 +2,16 @@ "use strict"; -import { base, config, earcut, util } from './base.js'; -import { calc_normal, calc_vertex, paths } from './paths.js'; +import { base, config, util } from './base.js'; +import { paths } from './paths.js'; import { ClipperLib } from '../ext/clip2.esm.js'; import { newBounds } from './bounds.js'; import { newPoint, pointFromClipper } from './point.js'; -import { newSlopeFromAngle } from './slope.js'; import { polygons as POLY } from './polygons.js'; +import * as geo from './poly/geo.js'; +import * as arcs from './poly/arcs.js'; +import * as mesh from './poly/mesh.js'; +import * as path from './poly/path.js'; const { Vector3 } = THREE; @@ -222,43 +225,7 @@ export class Polygon { } earcut() { - // 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() - this.points.forEach(p => { - out.push(p.x, p.y, p.z); - }); - - // add hole offsets for inner polygons - if (this.inner) { - this.inner.forEach(p => { - holes.push(out.length / 3); - p.points.forEach(p => { - out.push(p.x, p.y, p.z); - }) - }); - } - - // perform earcut() - let cut = earcut(out, holes, 3); - let ret = []; - - // preserve swaps in new polys - for (let i = 0; i < cut.length; i += 3) { - let p = new Polygon(); - p._aligned = this._aligned; - p._swapped = this._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; + return mesh.earcut(this); } setInner(inner) { @@ -271,314 +238,27 @@ export class Polygon { // of the polygon with the noodle removed (for the next pass) // todo: relocate this code to post.js noodle(width) { - let clone = this.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 }; + return path.noodle(this, width); } // generate center crossing point cloud // only used for fdm thin-wall type 1 (fdm/post.js) // todo: relocate this code to post.js centers(step, z, min, max, opt = {}) { - let cloud = [], - bounds = this.bounds, - lines = opt.lines || false, - stepoff = step / 2, - set = [this.points]; - - if (this.inner) { - for (let inner of this.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(poly) { - let lastp = poly.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); - poly.push(minp); - return true; - } - return false; - } - - // join points into polys - let polys = []; - let poly = []; - while (cloud.length) { - if (poly.length === 0) { - poly = [cloud.shift()]; - polys.push(poly); - continue; - } - if (build(poly)) { - continue; - } - if (!poly.flip) { - poly.reverse(); - poly.flip = true; - continue; - } - if (poly.length) { - poly = []; - } else { - throw "whoop there it is"; - } - } - - return polys - .filter(poly => poly.length > 1) - .map(poly => { - let np = newPolygon().setOpen(); - for (let p of poly) { - np.push(p); - } - if (np.last().distTo2D(np.first()) <= max) { - np.setClosed(); - } - np = np.clean(); - return np; - }); + return path.centers(this, step, z, min, max, opt); } debur(dist) { - if (this.len < 2) { - return null; - } - const pa = this.points, - pln = pa.length, - open = this.open, - newp = newPolygon().copyZ(this.z), - 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 = this.parent; - if (newp.length < 2) { - return null; - } - return newp; + return geo.debur(this, dist); } miter(debug) { - if (this.length < 3) return this; - - const slo = [], - pa = this.points, - pln = pa.length, - open = this.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[pln-1] = slopeDiff(slo[pln-2], slo[pln-1]); - ang[0] = slopeDiff(slo[pln - 1], slo[0]); - redo |= ang[pln - 1] > 90; - redo |= ang[0] > 90; - } - if (redo) { - const newp = newPolygon().copyZ(this.z); - // newp.debug = this.debug = true; - 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 this; + return geo.miter(this, debug); } // only used in fdm/slice.js for enclosing supports createConvexHull(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(); - this.addPoints(hull); - - return this; + return geo.createConvexHull(this, points); } stepsFromRoot() { @@ -653,29 +333,7 @@ export class Polygon { * @returns {Point} center of a polygon assuming it's a circle */ calcCircleCenter() { - let pe = this.perimeter(), - pe1 = pe / 3, // point 2 - pe2 = pe1 * 2, // point 3 - p = this.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); + return arcs.calcCircleCenter(this); } /** @@ -683,60 +341,7 @@ export class Polygon { * with a common center point and collect as a midpoint/radius */ findArcCenters(opt = {}) { - if (this.length < 6) return []; - let tolerance = opt.tolerance || 1e-2, - inside = opt.inside ?? true, - seq = this.points.slice(), - util = base.util; - if (this.isClosed()) seq.appendAll(seq.slice(0,5)); - let recs = [], // accumulated arc points - cand = []; // candidate center array - for (let pos=3; pos= 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; + return arcs.findArcCenters(this, opt); } /** @@ -751,189 +356,7 @@ export class Polygon { * @returns {Polygon} this polygon with arc annotations */ detectArcs(opts = {}) { - const { - tolerance = 0.1, - arcRes = 0.1, - minPoints = 4 - } = opts; - - const points = this.points; - const length = points.length; - - if (length < minPoints) { - return this; - } - - // 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 = this._detectArcAt(i, points, length, tolerance, arcRes, 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 this; - } - - /** - * Try to detect an arc starting at the given index - * Uses a greedy approach: grow the arc as long as possible, then validate - * @private - * @returns {Object|null} arc data or null if no arc detected - */ - _detectArcAt(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 = this._validateArc(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 this._validateArc(candidates, tolerance, arcRes, minPoints); - } - - /** - * Validate if a set of points forms a valid arc - * @private - */ - _validateArc(arcPoints, tolerance, arcRes, minPoints) { - if (arcPoints.length < minPoints) { - return null; - } - - // Calculate center using well-distributed points - const center = this._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 - */ - _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; + return arcs.detectArcs(this, opts); } /** @@ -1206,6 +629,16 @@ export class Polygon { return minZ; } + maxZ() { + let maxZ = Math.max(...this.points.map(p => p.z)); + if (this.inner) { + for (let i of this.inner) { + maxZ = Math.max(minZ, i.maxZ()); + } + } + return maxZ; + } + avgZ() { return [...this.points.map(p => p.z)].reduce((a,v) => a+v) / this.points.length; } @@ -1361,11 +794,12 @@ export class Polygon { return [this.first().key, this.last().key, this.length].join('~~'); } - applyRotations() { + applyRotations(deg) { for (let point of this.points) { - if (point.a) { + let angleDeg = deg ?? point.a; + if (angleDeg) { let p2 = new Vector3(point.x, point.y, point.z) - .applyAxisAngle(XAXIS, point.a * DEG2RAD); + .applyAxisAngle(XAXIS, angleDeg * DEG2RAD); point.x = p2.x; point.y = p2.y; point.z = p2.z; @@ -1461,27 +895,7 @@ export class Polygon { * find all intersections sorted by closest to lp1 */ intersections(lp1, lp2, deep) { - let list = []; - this.forEachSegment(function(pp1, pp2, ip1, ip2) { - let int = util.intersect(lp1, lp2, pp1, pp2, base.key.SEGINT, false); - if (int) { - list.push(int); - // console.log('pp1.pos',pp1.pos,'to',ip1); - // console.log('pp2.pos',pp2.pos,'to',ip2); - pp1.pos = ip1; - pp2.pos = ip2; - } - }); - if (deep && this.inner) { - this.inner.forEach(p => { - let ints = p.intersections(lp1, lp2); - if (ints) list.appendAll(ints); - }); - } - list.sort(function(p1, p2) { - return util.distSq(lp1, p1) - util.distSq(lp1, p2); - }); - return list; + return geo.intersections(this, lp1, lp2, deep); } // return true if any line segments on either poly crosses the other @@ -1807,6 +1221,21 @@ export class Polygon { return a; } + /** + * find the ratio of area to perimeter which can give provide + * guidance if this polygon is likely to have a thin cross-section + * + * @param {*} deep whether to include inner holes in the calculation + * @returns ratio + */ + thickness(deep) { + if (deep) { + return 2 * this.areaDeep() / this.perimeterDeep(); + } else { + return 2 * this.area() / this.perimeter(); + } + } + /** * @param {Polygon} poly * @returns {boolean} @@ -1943,60 +1372,7 @@ export class Polygon { * @returns {boolean} true if polygons are, essentially, the same */ isEquivalent(poly, recurse, precision) { - // throw new Error("isEquivalent"); - let area1 = Math.abs(this.area()); - let area2 = Math.abs(poly.area()); - if (util.isCloseTo(area1, area2, precision || config.precision_poly_area) && - this.bounds.equals(poly.bounds, precision || config.precision_poly_bounds)) { - // use circularity near 1 to eliminate the extensive check below - let c1 = this.circularity(), - c2 = poly.circularity(); - if (Math.abs(c1 - c2) < config.precision_circularity && ((1 - c1) < config.precision_circularity)) { - return true; - } - - if (recurse) { - let i, ai = this.inner, - bi = poly.inner; - if (ai !== bi) { - if (ai === null || bi === null || ai.length != bi.length) { - return false; - } - for (i = 0; i < ai.length; i++) { - if (!ai[i].isEquivalent(bi[i])) { - return false; - } - } - } - } - - let exit = true, - pointok, - dist, - min; - - this.forEachPoint(i2p => { - pointok = false; - poly.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; + return geo.isEquivalent(this, poly, recurse, precision); } /** @@ -2311,24 +1687,7 @@ export class Polygon { // turn 2d polygon into a 2.5D ribbon extruded in Z ribbonZ(z = 1, zadd = 0, rev) { - let poly = this.clone().setClockwise(); - let faces = []; - let points = poly.points; - let length = points.length; - if (rev) { - points = points.slice().reverse(); - } - for (let i=0; i 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 = earcut; - let bottom_face = earcut; - 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 = this.offset(chamfer_top); - if (inset.length === 1) { - inset[0].setZ(z_top); - top_face = inset[0].earcut(); - z_side_top -= chamfer_top; - let renest = POLY.renest([this.clone(true).setZ(z_side_top), inset[0]]); - for (let rnpoly of renest) { - obj.appendAll(rnpoly.ribbonMesh(true)); - } - } - } - - if (chamfer_bottom) { - let inset = this.offset(chamfer_bottom); - if (inset.length === 1) { - inset[0].setZ(0); - bottom_face = inset[0].earcut(); - z_side_top -= chamfer_top || chamfer_bottom; - z_side_bottom += chamfer_bottom; - let renest = POLY.renest([this.clone(true).setZ(z_side_bottom), inset[0]]); - for (let rnpoly of renest) { - obj.appendAll(rnpoly.ribbonMesh(false)); - } - } - } - - for (let poly of top_face) { - for (let point of poly.points) { - obj.push(point.x, point.y, z_top); - } - } - - // bottom face (reversed to reverse normals) - for (let poly of bottom_face) { - for (let point of poly.points.reverse()) { - obj.push(point.x, point.y, z_bottom); - } - } - - // outside wall - let rib = z - chamfer_top - chamfer_bottom; - obj.appendAll(this.ribbonZ(rib, z_side_bottom)); - for (let inner of this.inner || []) { - // inside wall(s) - obj.appendAll(inner.ribbonZ(rib, z_side_bottom, true)); - } - - if (inv) { - for (let i=2; i length) { - if (wrap) { - newp.push(newp[0]); - } - return newPolygon().addPoints(newp.map(p => p.clone(['segment']))).setOpenValue(this.open); - } - return this; + return path.segment(this, max, mark, wrap, maxoff); } // only used by cam/op-pocket.js // for any poly points closer than `dist`, replace them with their midpoint midpoints(dist = 0.01) { - const newp = []; - const points = this.points; - const length = points.length; - const l0 = this.open ? length - 1 : length; - let mod = 0; - for (let i=0, p=points; i p.clone())).setOpenValue(this.open); - } - return this; + return path.midpoints(this, dist); } // walk points noting z deltas and smoothing z sawtooth patterns // used to smooth low-rez surface contouring refine(passes = 0) { - for (let j = 0; j < passes; j++) { - let points = this.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 geo.refine(this, passes); } addDogbones(dist, reverse) { - let poly = this; - 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 => inner.addDogbones(dist, true)); - } + return path.addDogbones(this, dist, reverse); } } diff --git a/src/geo/polygons.js b/src/geo/polygons.js index 7f9c35f2..5f06696f 100644 --- a/src/geo/polygons.js +++ b/src/geo/polygons.js @@ -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= 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[]} diff --git a/src/geo/slicer.js b/src/geo/slicer.js index 3ce82017..4889a8cb 100644 --- a/src/geo/slicer.js +++ b/src/geo/slicer.js @@ -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 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} + */ 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} rec - Single alert record or array of records to hide + * @param {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 = []; diff --git a/src/kiri/app/api.js b/src/kiri/app/api.js new file mode 100644 index 00000000..9c687c4d --- /dev/null +++ b/src/kiri/app/api.js @@ -0,0 +1,294 @@ +/** Copyright Stewart Allen -- 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 = `x`; + $('_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; diff --git a/src/kiri/core/boxes.js b/src/kiri/app/boxes.js similarity index 100% rename from src/kiri/core/boxes.js rename to src/kiri/app/boxes.js diff --git a/src/kiri/app/color/base.js b/src/kiri/app/color/base.js new file mode 100644 index 00000000..7143df03 --- /dev/null +++ b/src/kiri/app/color/base.js @@ -0,0 +1,69 @@ +/** Copyright Stewart Allen -- 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 }; diff --git a/src/kiri/app/color/cam.js b/src/kiri/app/color/cam.js new file mode 100644 index 00000000..874562d1 --- /dev/null +++ b/src/kiri/app/color/cam.js @@ -0,0 +1,58 @@ +/** Copyright Stewart Allen -- 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 }; diff --git a/src/kiri/app/color/fdm.js b/src/kiri/app/color/fdm.js new file mode 100644 index 00000000..9a7f761d --- /dev/null +++ b/src/kiri/app/color/fdm.js @@ -0,0 +1,42 @@ +/** Copyright Stewart Allen -- 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 }; diff --git a/src/kiri/app/color/laser.js b/src/kiri/app/color/laser.js new file mode 100644 index 00000000..29143a22 --- /dev/null +++ b/src/kiri/app/color/laser.js @@ -0,0 +1,28 @@ +/** Copyright Stewart Allen -- 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 }; diff --git a/src/kiri/app/color/schemes.js b/src/kiri/app/color/schemes.js new file mode 100644 index 00000000..a139a3f2 --- /dev/null +++ b/src/kiri/app/color/schemes.js @@ -0,0 +1,145 @@ +/** Copyright Stewart Allen -- 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 }; diff --git a/src/kiri/app/color/sla.js b/src/kiri/app/color/sla.js new file mode 100644 index 00000000..455ce244 --- /dev/null +++ b/src/kiri/app/color/sla.js @@ -0,0 +1,23 @@ +/** Copyright Stewart Allen -- 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 }; diff --git a/src/kiri/core/conf.js b/src/kiri/app/conf/defaults.js similarity index 98% rename from src/kiri/core/conf.js rename to src/kiri/app/conf/defaults.js index 3e3a3c89..6ccd3143 100644 --- a/src/kiri/core/conf.js +++ b/src/kiri/app/conf/defaults.js @@ -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, diff --git a/src/kiri/app/conf/dialog.js b/src/kiri/app/conf/dialog.js new file mode 100644 index 00000000..042ceffe --- /dev/null +++ b/src/kiri/app/conf/dialog.js @@ -0,0 +1,123 @@ +/** Copyright Stewart Allen -- 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 = ''; + del.onclick = deleteSettings; + + edit.setAttribute('name', sk); + edit.setAttribute('title', 'edit'); + edit.innerHTML = ''; + edit.onclick = editSettings; + + xprt.setAttribute('name', sk); + xprt.setAttribute('title', 'export'); + xprt.innerHTML = ''; + 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 +}; diff --git a/src/kiri/core/settings.js b/src/kiri/app/conf/manager.js similarity index 94% rename from src/kiri/core/settings.js rename to src/kiri/app/conf/manager.js index c269a7b4..c15f57ec 100644 --- a/src/kiri/core/settings.js +++ b/src/kiri/app/conf/manager.js @@ -1,20 +1,31 @@ /** Copyright Stewart Allen -- 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, diff --git a/src/kiri/app/conf/settings.js b/src/kiri/app/conf/settings.js new file mode 100644 index 00000000..dadf0782 --- /dev/null +++ b/src/kiri/app/conf/settings.js @@ -0,0 +1,129 @@ +/** Copyright Stewart Allen -- 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: [ + "
", + "

Workspace Export

", + " ", + " ", + "  Exclude meshes", + "
" + ]}; + 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, +}; diff --git a/src/kiri/core/consts.js b/src/kiri/app/consts.js similarity index 85% rename from src/kiri/core/consts.js rename to src/kiri/app/consts.js index fa4e45af..ac59923b 100644 --- a/src/kiri/core/consts.js +++ b/src/kiri/app/consts.js @@ -1,21 +1,6 @@ /** Copyright Stewart Allen -- 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 }; diff --git a/src/kiri/core/devices.js b/src/kiri/app/devices.js similarity index 83% rename from src/kiri/core/devices.js rename to src/kiri/app/devices.js index 82a867d7..77b8f1e7 100644 --- a/src/kiri/core/devices.js +++ b/src/kiri/app/devices.js @@ -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, diff --git a/src/kiri/core/do.js b/src/kiri/app/do.js similarity index 78% rename from src/kiri/core/do.js rename to src/kiri/app/do.js index b3b998c6..bf9836f3 100644 --- a/src/kiri/core/do.js +++ b/src/kiri/app/do.js @@ -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); diff --git a/src/kiri/core/export.js b/src/kiri/app/export.js similarity index 77% rename from src/kiri/core/export.js rename to src/kiri/app/export.js index 28ba7927..1479f36f 100644 --- a/src/kiri/core/export.js +++ b/src/kiri/app/export.js @@ -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) diff --git a/src/kiri/core/tools.js b/src/kiri/app/face-tool.js similarity index 62% rename from src/kiri/core/tools.js rename to src/kiri/app/face-tool.js index 6802b718..07df927f 100644 --- a/src/kiri/core/tools.js +++ b/src/kiri/app/face-tool.js @@ -1,20 +1,39 @@ /** Copyright Stewart Allen -- 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, +}; diff --git a/src/kiri/app/file-ops.js b/src/kiri/app/file-ops.js new file mode 100644 index 00000000..e0cd1054 --- /dev/null +++ b/src/kiri/app/file-ops.js @@ -0,0 +1,159 @@ +/** Copyright Stewart Allen -- 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 { + 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 = ''; + + 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 +}; diff --git a/src/kiri/core/files.js b/src/kiri/app/files.js similarity index 72% rename from src/kiri/core/files.js rename to src/kiri/app/files.js index 0067915e..978e5886 100644 --- a/src/kiri/core/files.js +++ b/src/kiri/app/files.js @@ -1,6 +1,15 @@ /** Copyright Stewart Allen -- 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 -- 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 => { diff --git a/src/kiri/core/function.js b/src/kiri/app/function.js similarity index 76% rename from src/kiri/core/function.js rename to src/kiri/app/function.js index 47fb4d06..2a6f5e0d 100644 --- a/src/kiri/core/function.js +++ b/src/kiri/app/function.js @@ -1,14 +1,43 @@ /** Copyright Stewart Allen -- 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 = {}; } diff --git a/src/kiri/app/groups.js b/src/kiri/app/groups.js new file mode 100644 index 00000000..fd189742 --- /dev/null +++ b/src/kiri/app/groups.js @@ -0,0 +1,24 @@ +/** Copyright Stewart Allen -- 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, +}; diff --git a/src/kiri/app/help.js b/src/kiri/app/help.js new file mode 100644 index 00000000..aec228f7 --- /dev/null +++ b/src/kiri/app/help.js @@ -0,0 +1,36 @@ +/** Copyright Stewart Allen -- 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 +}; diff --git a/src/kiri/app/image.js b/src/kiri/app/image.js new file mode 100644 index 00000000..97fda375 --- /dev/null +++ b/src/kiri/app/image.js @@ -0,0 +1,103 @@ +/** Copyright Stewart Allen -- 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
Consider resizing under 1000 x 1000
Proceed with import?").then(ok => { + if (ok) { + loadImageDialog(image, name, true); + } + }); + } + const opt = {pre: [ + "
", + "

Image Conversion

", + "

", + " 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.", + "

", + "
", + " ", + " ", + " ", + " ", + " ", + " ", + "
blur value invert image
base size invert alpha
border size
", + "
" + ]}; + 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 = ``; + + 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 +}; diff --git a/src/kiri/app/init/bind.js b/src/kiri/app/init/bind.js new file mode 100644 index 00000000..0debc56e --- /dev/null +++ b/src/kiri/app/init/bind.js @@ -0,0 +1,134 @@ +/** Copyright Stewart Allen -- 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'), + }; +} diff --git a/src/kiri/app/init/input.js b/src/kiri/app/init/input.js new file mode 100644 index 00000000..040e2dce --- /dev/null +++ b/src/kiri/app/init/input.js @@ -0,0 +1,546 @@ +/** Copyright Stewart Allen -- 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:''}), + ui.extAdd = newButton(undefined, undefined, {icon:''}), + ui.extDel = newButton(undefined, undefined, {icon:''}), + ui.extNext = newButton(undefined, undefined, {icon:''}) + ], {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)); + }); +}; diff --git a/src/kiri/app/init/lang.js b/src/kiri/app/init/lang.js new file mode 100644 index 00000000..5e33c039 --- /dev/null +++ b/src/kiri/app/init/lang.js @@ -0,0 +1,69 @@ +/** Copyright Stewart Allen -- 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(); + } + }); +} diff --git a/src/kiri/app/init/sync.js b/src/kiri/app/init/sync.js new file mode 100644 index 00000000..a5ed5915 --- /dev/null +++ b/src/kiri/app/init/sync.js @@ -0,0 +1,324 @@ +/** Copyright Stewart Allen -- 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('caution: 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 grid.space 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; +} diff --git a/src/kiri/core/ui.js b/src/kiri/app/inputs.js similarity index 77% rename from src/kiri/core/ui.js rename to src/kiri/app/inputs.js index d4edf9e2..2a3a05a1 100644 --- a/src/kiri/core/ui.js +++ b/src/kiri/app/inputs.js @@ -1,31 +1,62 @@ /** Copyright Stewart Allen -- 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} 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} 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} 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} [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} 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} 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 => { diff --git a/src/kiri/app/keyboard.js b/src/kiri/app/keyboard.js new file mode 100644 index 00000000..7d6e49fc --- /dev/null +++ b/src/kiri/app/keyboard.js @@ -0,0 +1,450 @@ +/** Copyright Stewart Allen -- 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(); diff --git a/src/kiri/core/lang-en.js b/src/kiri/app/lang-en.js similarity index 100% rename from src/kiri/core/lang-en.js rename to src/kiri/app/lang-en.js diff --git a/src/kiri/core/lang.js b/src/kiri/app/language.js similarity index 69% rename from src/kiri/core/lang.js rename to src/kiri/app/language.js index 187f590b..7e955f30 100644 --- a/src/kiri/core/lang.js +++ b/src/kiri/app/language.js @@ -1,10 +1,23 @@ /** Copyright Stewart Allen -- 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 diff --git a/src/kiri/core/layers.js b/src/kiri/app/layers.js similarity index 68% rename from src/kiri/core/layers.js rename to src/kiri/app/layers.js index 6a585825..efbaf061 100644 --- a/src/kiri/core/layers.js +++ b/src/kiri/app/layers.js @@ -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} 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} 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} 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} 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} polys - Polygon(s) to flatten + * @returns {Array} Flattened polygon array + */ function flat(polys) { if (Array.isArray(polys)) { return POLY.flatten(polys.clone(true), [], true); diff --git a/src/kiri/app/local.js b/src/kiri/app/local.js new file mode 100644 index 00000000..3afba384 --- /dev/null +++ b/src/kiri/app/local.js @@ -0,0 +1,55 @@ +/** Copyright Stewart Allen -- 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) +}; diff --git a/src/kiri/app/modal.js b/src/kiri/app/modal.js new file mode 100644 index 00000000..74d19fa8 --- /dev/null +++ b/src/kiri/app/modal.js @@ -0,0 +1,119 @@ +/** Copyright Stewart Allen -- 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(); diff --git a/src/kiri/app/mode.js b/src/kiri/app/mode.js new file mode 100644 index 00000000..7f992d9d --- /dev/null +++ b/src/kiri/app/mode.js @@ -0,0 +1,148 @@ +/** Copyright Stewart Allen -- 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 +}; diff --git a/src/kiri/app/mouse.js b/src/kiri/app/mouse.js new file mode 100644 index 00000000..836f19c9 --- /dev/null +++ b/src/kiri/app/mouse.js @@ -0,0 +1,218 @@ +/** Copyright Stewart Allen -- 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(); diff --git a/src/kiri/core/pack.js b/src/kiri/app/pack.js similarity index 100% rename from src/kiri/core/pack.js rename to src/kiri/app/pack.js diff --git a/src/kiri/app/paint.js b/src/kiri/app/paint.js new file mode 100644 index 00000000..7b76ac5d --- /dev/null +++ b/src/kiri/app/paint.js @@ -0,0 +1,555 @@ +/** Copyright Stewart Allen -- 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 `, + ` + #include + 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 `, + ` + #include + + // 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 `, + ` + #include + 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 `, + ` + #include + + // 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 +}; diff --git a/src/kiri/core/platform.js b/src/kiri/app/platform.js similarity index 69% rename from src/kiri/core/platform.js rename to src/kiri/app/platform.js index e349dfb2..a1529fb6 100644 --- a/src/kiri/core/platform.js +++ b/src/kiri/app/platform.js @@ -1,32 +1,65 @@ /** Copyright Stewart Allen -- 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(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} 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: [ "
", @@ -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, diff --git a/src/kiri/app/preferences.js b/src/kiri/app/preferences.js new file mode 100644 index 00000000..8facac53 --- /dev/null +++ b/src/kiri/app/preferences.js @@ -0,0 +1,129 @@ +/** Copyright Stewart Allen -- 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 +}; diff --git a/src/kiri/app/selected.js b/src/kiri/app/selected.js new file mode 100644 index 00000000..b4421645 --- /dev/null +++ b/src/kiri/app/selected.js @@ -0,0 +1,602 @@ +/** Copyright Stewart Allen -- 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} [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 { + 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} 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() }, +}; diff --git a/src/kiri/app/slider.js b/src/kiri/app/slider.js new file mode 100644 index 00000000..2d5d2bd8 --- /dev/null +++ b/src/kiri/app/slider.js @@ -0,0 +1,309 @@ +/** Copyright Stewart Allen -- 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(); diff --git a/src/kiri/core/stack.js b/src/kiri/app/stack.js similarity index 100% rename from src/kiri/core/stack.js rename to src/kiri/app/stack.js diff --git a/src/kiri/core/stacks.js b/src/kiri/app/stacks.js similarity index 100% rename from src/kiri/core/stacks.js rename to src/kiri/app/stacks.js diff --git a/src/kiri/app/state/visual.js b/src/kiri/app/state/visual.js new file mode 100644 index 00000000..ca2f6429 --- /dev/null +++ b/src/kiri/app/state/visual.js @@ -0,0 +1,215 @@ +/** Copyright Stewart Allen -- 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} + */ + 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 }; diff --git a/src/kiri/core/stats.js b/src/kiri/app/stats.js similarity index 96% rename from src/kiri/core/stats.js rename to src/kiri/app/stats.js index d7fcb1a9..5f36f536 100644 --- a/src/kiri/core/stats.js +++ b/src/kiri/app/stats.js @@ -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) { diff --git a/src/kiri/app/view-mode.js b/src/kiri/app/view-mode.js new file mode 100644 index 00000000..dbb87dc9 --- /dev/null +++ b/src/kiri/app/view-mode.js @@ -0,0 +1,112 @@ +/** Copyright Stewart Allen -- 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 } +}; diff --git a/src/kiri/app/visuals.js b/src/kiri/app/visuals.js new file mode 100644 index 00000000..a5f0af73 --- /dev/null +++ b/src/kiri/app/visuals.js @@ -0,0 +1,390 @@ +/** Copyright Stewart Allen -- 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(``); + }); + 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' ? + '

' : + `
${row[0]}
` ; + }).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 +}; diff --git a/src/kiri/app/widget.js b/src/kiri/app/widget.js new file mode 100644 index 00000000..487fded3 --- /dev/null +++ b/src/kiri/app/widget.js @@ -0,0 +1,404 @@ +/** Copyright Stewart Allen -- 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 }; diff --git a/src/kiri/core/widgets.js b/src/kiri/app/widgets.js similarity index 97% rename from src/kiri/core/widgets.js rename to src/kiri/app/widgets.js index 10541e74..94adf087 100644 --- a/src/kiri/core/widgets.js +++ b/src/kiri/app/widgets.js @@ -1,9 +1,9 @@ /** Copyright Stewart Allen -- 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) }, diff --git a/src/kiri/core/client.js b/src/kiri/app/workers.js similarity index 91% rename from src/kiri/core/client.js rename to src/kiri/app/workers.js index a6a5a116..8e7148df 100644 --- a/src/kiri/core/client.js +++ b/src/kiri/app/workers.js @@ -1,10 +1,12 @@ /** Copyright Stewart Allen -- 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); } }); }, diff --git a/src/kiri/app/workspace.js b/src/kiri/app/workspace.js new file mode 100644 index 00000000..faae03d9 --- /dev/null +++ b/src/kiri/app/workspace.js @@ -0,0 +1,234 @@ +/** Copyright Stewart Allen -- 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} [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, +}; diff --git a/src/kiri/core/api.js b/src/kiri/core/api.js deleted file mode 100644 index 4d8b328f..00000000 --- a/src/kiri/core/api.js +++ /dev/null @@ -1,200 +0,0 @@ -/** Copyright Stewart Allen -- 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; diff --git a/src/kiri/core/codec.js b/src/kiri/core/codec.js index ec0da847..ee102444 100644 --- a/src/kiri/core/codec.js +++ b/src/kiri/core/codec.js @@ -1,12 +1,13 @@ /** Copyright Stewart Allen -- 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 }; diff --git a/src/kiri/core/init.js b/src/kiri/core/init.js deleted file mode 100644 index c311a06d..00000000 --- a/src/kiri/core/init.js +++ /dev/null @@ -1,1867 +0,0 @@ -/** Copyright Stewart Allen -- All Rights Reserved */ - -import { $, $c } from '../../moto/webui.js'; -import { api } from './api.js'; -import { base } from '../../geo/base.js'; -import { local as sdb } from '../../data/local.js'; -import { space } from '../../moto/space.js'; -import { LOCAL, SETUP } from './main.js'; -import { VIEWS, MODES, SEED } from './consts.js'; -import { beta, version } from '../../moto/license.js'; -import { settings as set_ctrl } from './settings.js'; -import { init as initCAM } from '../mode/cam/client.js'; -import { init as initDRAG } from '../mode/drag/client.js'; -import { init as initFDM } from '../mode/fdm/client.js'; -import { init as initLaser } from '../mode/laser/client.js'; -import { init as initSLA } from '../mode/sla/client.js'; -import { init as initWEDM } from '../mode/wedm/client.js'; -import { init as initWJET } from '../mode/wjet/client.js'; -import { menu as menuCAM } from '../mode/cam/init-menu.js'; -import { menu as menuFDM } from '../mode/fdm/init-menu.js'; -import { menu as menuLaser } from '../mode/laser/init-menu.js'; -import { menu as menuSLA } from '../mode/sla/init-menu.js'; -import './tools.js'; - -let { CAM, SLA, FDM, LASER, DRAG, WJET, WEDM } = MODES, - { client, catalog, platform, selection, stats } = api, - LANG = api.language.current, - WIN = self.window, - DOC = self.document, - STARTMODE = SETUP.sm && SETUP.sm.length === 1 ? SETUP.sm[0] : null, - 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 ], - proto = location.protocol, - platformColor, - separator = true, - hideable = true, - 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(); - }); - return true; - } - } - return false; -} - -function unitsSave() { - api.conf.update({ controller: true }); - platform.update_size(); -} - -function aniMeshSave() { - api.conf.update({ controller: true }); - api.conf.save(); -} - -function lineTypeSave() { - const sel = ui.lineType.options[ui.lineType.selectedIndex]; - if (sel) { - settings().controller.lineType = sel.value; - api.conf.save(); - } -} - -function detailSave() { - 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() { - settings().controller.showSpeeds = ui.showSpeeds.checked; - api.view.update_speeds(); -} - -function setThreaded(bool) { - if (bool) { - client.pool.start(); - } else { - client.pool.stop(); - } - return bool; -} - -// upon restore, seed presets -api.event.emit('preset', api.conf.dbo()); - -api.event.on("set.threaded", bool => setThreaded(bool)); - -function booleanSave() { - 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(); - updateStats(); - 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' : ''); -} - -function updateStats() { - if (self.debug !== true) { - return; - } - let { div, fps, rms, rnfo } = ui.stats; - div.style.display = 'flex'; - 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({ ...memory, ...render, render_ms: nrms, frames_sec: nfps }).map(row => { - return `
${row[0]}
` - }).join(''); - } - }, 100); -} - -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 inputHasFocus() { - let active = DOC.activeElement; - return active && (active.nodeName === "INPUT" || active.nodeName === "TEXTAREA"); -} - -function cca(c) { - return c.charCodeAt(0); -} - -function keyUpHandler(evt) { - if (api.feature.on_key) { - if (api.feature.on_key({up:evt})) return; - } - for (let handler of api.feature.on_key2) { - if (handler({up:evt})) return; - } - switch (evt.keyCode) { - // escape - case 27: - // blur text input focus - DOC.activeElement.blur(); - // dismiss modals - api.modal.hide(); - // deselect widgets - platform.deselect(); - // hide all dialogs - api.dialog.hide(); - // cancel slicing - api.function.cancel(); - // and send an event (used by FDM client) - api.event.emit("key.esc"); - break; - } - return false; -} - -function keyDownHandler(evt) { - if (api.modal.visible()) { - return false; - } - if (api.feature.on_key) { - if (api.feature.on_key({down:evt})) return; - } - for (let handler of 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 (inputHasFocus()) return false; - platform.delete(selection.meshes()); - evt.preventDefault(); - break; - case 37: // left arrow - if (inputHasFocus()) return false; - if (deg) selection.rotate(0, 0, -deg); - if (move > 0) selection.move(-move, 0, 0); - evt.preventDefault(); - break; - case 39: // right arrow - if (inputHasFocus()) return false; - if (deg) selection.rotate(0, 0, deg); - if (move > 0) selection.move(move, 0, 0); - evt.preventDefault(); - break; - case 38: // up arrow - if (inputHasFocus()) return false; - if (evt.metaKey) return api.show.layer(api.var.layer_at+1); - if (deg) selection.rotate(deg, 0, 0); - if (move > 0) selection.move(0, move, 0); - evt.preventDefault(); - break; - case 40: // down arrow - if (inputHasFocus()) return false; - if (evt.metaKey) return api.show.layer(api.var.layer_at-1); - if (deg) selection.rotate(-deg, 0, 0); - if (move > 0) selection.move(0, -move, 0); - evt.preventDefault(); - break; - case 65: // 'a' for select all - if (evt.metaKey || evt.ctrlKey) { - if (inputHasFocus()) return false; - evt.preventDefault(); - platform.deselect(); - platform.select_all(); - } - break; - case 83: // 's' for save workspace - if (evt.ctrlKey) { - evt.preventDefault(); - api.conf.save(); - console.log("settings saved"); - } else - if (evt.metaKey) { - evt.preventDefault(); - api.space.save(); - set_ctrl.sync.put(); - } - break; - case 76: // 'l' for restore workspace - if (evt.metaKey) { - evt.preventDefault(); - api.space.restore(); - } - break; - } -} - -function keyHandler(evt) { - let handled = true; - if (api.modal.visible() || inputHasFocus()) { - return false; - } - if (api.feature.on_key) { - if (api.feature.on_key({key:evt})) return; - } - for (let handler of api.feature.on_key2) { - if (handler({key:evt})) return; - } - if (evt.ctrlKey) { - switch (evt.key) { - case 'g': return api.group.merge(); - case 'u': return api.group.split(); - } - } - switch (evt.charCode) { - case cca('`'): api.show.slices(0); break; - case cca('0'): api.show.slices(api.var.layer_max); break; - case cca('1'): api.show.slices(api.var.layer_max/10); break; - case cca('2'): api.show.slices(api.var.layer_max*2/10); break; - case cca('3'): api.show.slices(api.var.layer_max*3/10); break; - case cca('4'): api.show.slices(api.var.layer_max*4/10); break; - case cca('5'): api.show.slices(api.var.layer_max*5/10); break; - case cca('6'): api.show.slices(api.var.layer_max*6/10); break; - case cca('7'): api.show.slices(api.var.layer_max*7/10); break; - case cca('8'): api.show.slices(api.var.layer_max*8/10); break; - case cca('9'): api.show.slices(api.var.layer_max*9/10); break; - case cca('?'): - api.help.show(); - break; - case cca('Z'): // reset stored state - uc.confirm('clear all settings and preferences?').then(yes => { - if (yes) { - sdb.clear(); - WIN.location.reload(); - } - }); - break; - case cca('C'): // refresh catalog - catalog.refresh(); - break; - case cca('i'): // file import - api.event.import(); - break; - case cca('S'): // slice - case cca('s'): // slice - api.function.slice(); - break; - case cca('P'): // prepare - case cca('p'): // prepare - if (api.mode.get() !== 'SLA') { - // hidden in SLA mode - api.function.prepare(); - } - break; - case cca('X'): // export - case cca('x'): // export - api.function.export(); - break; - case cca('g'): // CAM animate - api.function.animate(); - break; - case cca('O'): // manual rotation - rotateInputSelection(); - break; - case cca('r'): // recent files - api.modal.show('files'); - break; - case cca('q'): // preferences - api.modal.show('prefs'); - break; - case cca('l'): // device - settingsLoad(); - break; - case cca('e'): // device - api.show.devices(); - break; - case cca('o'): // tools - api.show.tools(); - break; - case cca('c'): // local devices - api.show.local(); - break; - case cca('v'): // toggle single slice view mode - if (api.view.get() === VIEWS.ARRANGE) { - api.space.set_focus(selection.widgets()); - } - if (api.var.layer_hi == api.var.layer_lo) { - api.var.layer_lo = 0; - } else { - api.var.layer_lo = api.var.layer_hi; - } - api.show.slices(); - break; - case cca('d'): // duplicate object - duplicateSelection(); - break; - case cca('m'): // mirror object - mirrorSelection(); - break; - case cca('a'): - if (api.view.get() === VIEWS.ARRANGE) { - // auto arrange items on platform - platform.layout(); - if (!api.conf.get().controller.spaceRandoX) { - api.space.set_focus(selection.widgets()); - } - } else { - // go to arrange view - api.view.set(VIEWS.ARRANGE); - } - break; - default: - api.event.emit('keypress', evt); - handled = false; - break; - } - if (handled) { - evt.preventDefault(); - evt.stopPropagation(); - } - return false; -} - -function duplicateSelection() { - selection.duplicate(); -} - -function mirrorSelection() { - selection.mirror(); -} - -function keys(o) { - let key, list = []; - for (key in o) { if (o.hasOwnProperty(key)) list.push(key) } - return list.sort(); -} - -function clearSelected(children) { - for (let i=0; i { - 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); - }); -} - -function positionSelection() { - 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 = boundsSelection(); - - 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); - }); -} - -function deviceExport(exp, name) { - name = (name || "device") - .toLowerCase() - .replace(/ /g,'_') - .replace(/\./g,'_'); - uc.prompt("Export Device Filename", name).then(name => { - if (name) { - api.util.download(exp, `${name}.km`); - } - }); -} - -function objectsExport(format = "stl") { - // return selection.export(); - 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(selection.export(format), name); - }); -} - -function workspaceNew() { - 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 profileExport() { - const opt = {pre: [ - "
", - "

Workspace Export

", - " ", - " ", - "  Exclude meshes", - "
" - ]}; - let suggestion = "workspace"; - let file = api.widgets.all()[0]?.meta.file || ''; - if (file) { - suggestion = `${suggestion}_${file.split('.')[0]}`.replaceAll(' ','_'); - }; - 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}); - - 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 settingsSave(ev, name) { - if (ev) { - ev.stopPropagation(); - ev.preventDefault(); - } - - api.dialog.hide(); - let mode = api.mode.get(), - s = settings(), - 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(); - sync_put(); - }; - - if (name) { - saveAs(name); - } else { - uc.prompt("Save Settings As", cp ? lp || def : def).then(saveAs); - } -} - -function settingsLoad() { - api.conf.show(); -} - -function updateDeviceSize() { - api.conf.update(); - platform.update_size(); - platform.update_origin(); -} - -async function sync_put() { - await set_ctrl.sync.put(); -} - -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; -} - -function dragLeave() { - space.platform.setColor(platformColor); -} - -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) { - uc.confirm(`group ${files.length} files?`).then(yes => { - api.platform.load_files(files, yes ? [] : undefined); - }); - } -} - -function loadCatalogFile(e) { - api.widgets.load(e.target.getAttribute('load'), function(widget) { - platform.add(widget); - api.dialog.hide(); - }); -} - -function updateCatalog(files) { - let table = ui.catalogList, - 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 { - api.uc.prompt(`rename file`, short).then(newname => { - if (newname && newname !== short) { - 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 = () => { catalog.deleteFile(name) }; - del.innerHTML = ''; - - 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); - } -} - -function isBelt() { - return api.device.isBelt(); -} - -function isNotBelt() { - return !isBelt(); -} - -// MAIN INITIALIZATION FUNCTION -function init_one() { - let { event, conf, view, show } = api, - { bound, toInt, toFloat } = uc, - { newBlank, newButton, newBoolean, newGroup, newInput } = uc, - { newSelect, newLabel, newValue, newRow, newGCode, newDiv } = uc; - - event.emit('init.one'); - - // ensure we have settings from last session - conf.restore(); - - let container = $('container'), - gcode = $('dev-gcode'), - tracker = $('tracker'), - controller = settings().controller; - - WIN.addEventListener("resize", () => { - event.emit('resize'); - }); - - event.on('resize', () => { - if (WIN.innerHeight < 800) { - ui.modalBox.classList.add('mh85'); - } else { - ui.modalBox.classList.remove('mh85'); - } - view.update_slider(); - }); - - space.sky.showGrid(false); - space.sky.setColor(controller.dark ? 0 : 0xffffff); - space.setAntiAlias(controller.antiAlias); - space.init(container, function (delta) { - let vars = api.var; - if (vars.layer_max === 0 || !delta) return; - if (controller.reverseZoom) delta = -delta; - let same = vars.layer_hi === vars.layer_lo; - let track = vars.layer_lo > 0; - if (delta > 0) { - vars.layer_hi = Math.max(same ? 0 : vars.layer_lo, vars.layer_hi - 1); - if (track) { - vars.layer_lo = Math.max(0, vars.layer_lo - 1); - } - } else if (delta < 0) { - vars.layer_hi = Math.min(vars.layer_max, vars.layer_hi + 1); - if (track) { - vars.layer_lo = Math.min(vars.layer_hi, vars.layer_lo + 1); - } - } - if (same) { - vars.layer_lo = vars.layer_hi; - } - 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); - updateDrawer(); - - // api augmentation with local functions - api.device.export = deviceExport; - - Object.assign(ui, { - tracker: tracker, - container: container, - - 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'), - }, - - catalogBody: $('catalogBody'), - catalogList: $('catalogList'), - - devices: $('devices'), - deviceAdd: $('device-add'), - deviceDelete: $('device-del'), - deviceRename: $('device-ren'), - deviceExport: $('device-exp'), - deviceSave: $('device-save'), - - setMenu: $('set-menu'), - settings: $('settings'), - settingsBody: $('settingsBody'), - settingsList: $('settingsList'), - - slider: $('slider'), - sliderMax: $('slider-max'), - sliderMin: $('slider-zero'), - sliderLo: $('slider-lo'), - sliderMid: $('slider-mid'), - sliderHi: $('slider-hi'), - sliderHold: $('slider-hold'), - sliderRange: $('slider-center'), - - loading: $('progress').style, - progress: $('progbar').style, - prostatus: $('progtxt'), - - selection: $('selection'), - sizeX: $('size_x'), - sizeY: $('size_y'), - sizeZ: $('size_z'), - scaleX: $('scale_x'), - scaleY: $('scale_y'), - scaleZ: $('scale_z'), - lockX: $('lock_x'), - lockY: $('lock_y'), - lockZ: $('lock_z'), - stock: $('stock'), - stockWidth: $('stock-width'), - stockDepth: $('stock-width'), - stockHeight: $('stock-width'), - - /** 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:updateDeviceSize}), - bedDepth: newInput('Y (depth)', {title:LANG.dv_bedw_l, convert:toFloat, size:6, units, round:2, action:updateDeviceSize}), - maxHeight: newInput('Z (height)', {title:LANG.dv_bedw_l, convert:toFloat, size:6, units, round:2, action:updateDeviceSize}), - 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}), - 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:''}), - ui.extAdd = newButton(undefined, undefined, {icon:''}), - ui.extDel = newButton(undefined, undefined, {icon:''}), - ui.extNext = newButton(undefined, undefined, {icon:''}) - ], {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, booleanSave, {title:LANG.op_anta_l}), - reverseZoom: newBoolean(LANG.op_invr_s, booleanSave, {title:LANG.op_invr_l}), - ortho: newBoolean(LANG.op_orth_s, booleanSave, {title:LANG.op_orth_l}), - dark: newBoolean(LANG.op_dark_s, booleanSave, {title:LANG.op_dark_l}), - drawer: newBoolean('slide out', booleanSave, {title:'slide out settings drawer'}), - scrolls: newBoolean('scrollbars', booleanSave, {title:'show scrollbars'}), - devel: newBoolean(LANG.op_devl_s, booleanSave, {title:LANG.op_devl_l}), - _____: newGroup(LANG.op_disp, $('prefs-gen2'), {inline}), - showOrigin: newBoolean(LANG.op_shor_s, booleanSave, {title:LANG.op_shor_l}), - showRulers: newBoolean(LANG.op_shru_s, booleanSave, {title:LANG.op_shru_l}), - showSpeeds: newBoolean(LANG.op_sped_s, speedSave, {title:LANG.op_sped_l}), - shiny: newBoolean(LANG.op_shny_s, booleanSave, {title:LANG.op_shny_l, modes:FDM}), - lineType: newSelect(LANG.op_line_s, {title: LANG.op_line_l, action: lineTypeSave, modes:FDM}, "linetype"), - manifold: newBoolean(LANG.op_mani_s, booleanSave, {title: LANG.op_mani_l, modes:CAM}, "manifold"), - animesh: newSelect(LANG.op_anim_s, {title: LANG.op_anim_l, action: aniMeshSave, modes:CAM}, "animesh"), - units: newSelect(LANG.op_unit_s, {title: LANG.op_unit_l, action: 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, booleanSave, {title:LANG.op_save_l}), - autoLayout: newBoolean(LANG.op_auto_s, booleanSave, {title:LANG.op_auto_l}), - freeLayout: newBoolean(LANG.op_free_s, booleanSave, {title:LANG.op_free_l}), - spaceRandoX: newBoolean(LANG.op_spcx_s, 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}), - exportOcto: newBoolean(`OctoPrint`, booleanSave, {title:LANG.op_exop_l}), - exportThumb: newBoolean(`Thumbnail`, booleanSave, {modes:FDM}), - exportPreview: newBoolean(`Code Preview`, booleanSave), - _____: newGroup(LANG.pt_menu, $('prefs-prt'), {inline}), - detail: newSelect(LANG.pt_qual_s, {title: LANG.pt_qual_l, action: detailSave}, "detail"), - healMesh: newBoolean(LANG.pt_heal_s, booleanSave, {title: LANG.pt_heal_l}), - threaded: newBoolean(LANG.pt_thrd_s, booleanSave, {title: LANG.pt_thrd_l, modes:THREED}), - assembly: newBoolean(LANG.pt_assy_s, booleanSave, {title: LANG.pt_assy_l, modes:THREED}), - webGPU: newBoolean(LANG.pt_wgpu_s, 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")), - - settingsName: $('settingsName'), - settingsSave: $('settingsSave'), - }); - - // 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 = () => { - settingsSave(undefined, ui.settingsName.value); - }; - - // slider setup - const mobile = space.info.mob; - const slbar = mobile ? 80 : 30; - const slbar2 = slbar * 2; - const slider = ui.sliderRange; - const drag = { }; - - if (mobile) { - ui.slider.classList.add('slider-mobile'); - ui.sliderLo.classList.add('slider-mobile'); - ui.sliderHi.classList.add('slider-mobile'); - // add css style for mobile devices - DOC.body.classList.add('mobile'); - } - - function pxToInt(txt) { - return txt ? parseInt(txt.substring(0,txt.length-2)) : 0; - } - - function sliderUpdate() { - let start = drag.low / drag.maxval; - let end = (drag.low + drag.mid - slbar) / drag.maxval; - api.event.emit('slider.pos', { start, end }); - api.var.layer_lo = Math.round(start * api.var.layer_max); - api.var.layer_hi = Math.round(end * api.var.layer_max); - api.show.layer(); - space.scene.active(); - } - - function dragit(el, delta) { - el.ontouchstart = el.onmousedown = (ev) => { - // el.classList.add('sli-drag-el'); - tracker.style.display = 'block'; - ev.stopPropagation(); - let obj = (ev.touches ? ev.touches[0] : ev); - drag.width = slider.clientWidth; - drag.maxval = drag.width - slbar2; - drag.start = obj.screenX; - drag.loat = drag.low = pxToInt(ui.sliderHold.style.marginLeft); - drag.mdat = drag.mid = ui.sliderMid.clientWidth; - drag.hiat = pxToInt(ui.sliderHold.style.marginRight); - drag.mdmax = drag.width - slbar - drag.loat; - drag.himax = drag.width - slbar - drag.mdat; - let cancel_drag = tracker.ontouchend = tracker.onmouseup = (ev) => { - // el.classList.remove('sli-drag-el'); - if (ev) { - ev.stopPropagation(); - ev.preventDefault(); - } - slider.onmousemove = undefined; - tracker.style.display = 'none'; - }; - el.ontouchend = cancel_drag; - el.ontouchmove = tracker.ontouchmove = tracker.onmousemove = (ev) => { - ev.stopPropagation(); - ev.preventDefault(); - if (ev.buttons === 0) { - return cancel_drag(); - } - if (delta) { - let obj = (ev.touches ? ev.touches[0] : ev); - delta(obj.screenX - drag.start); - } - }; - }; - } - - dragit(ui.sliderLo, (delta) => { - let midval = drag.mdat - delta; - let lowval = drag.loat + delta; - if (midval < slbar || lowval < 0) { - return; - } - ui.sliderHold.style.marginLeft = `${lowval}px`; - ui.sliderMid.style.width = `${midval}px`; - drag.low = lowval; - drag.mid = midval; - sliderUpdate(); - }); - dragit(ui.sliderMid, (delta) => { - let loval = drag.loat + delta; - let hival = drag.hiat - delta; - if (loval < 0 || hival < 0) return; - ui.sliderHold.style.marginLeft = `${loval}px`; - ui.sliderHold.style.marginRight = `${hival}px`; - drag.low = loval; - sliderUpdate(); - }); - dragit(ui.sliderHi, (delta) => { - let midval = drag.mdat + delta; - let hival = drag.hiat - delta; - if (midval < slbar || midval > drag.mdmax || hival < 0) return; - ui.sliderMid.style.width = `${midval}px`; - ui.sliderHold.style.marginRight = `${hival}px`; - drag.mid = midval; - sliderUpdate(); - }); - - ui.load.onchange = function(event) { - api.platform.load_files(event.target.files); - ui.load.value = ''; // reset so you can re-import the same filee - }; - - ui.sliderMin.onclick = () => { - api.show.layer(0,0); - } - - ui.sliderMax.onclick = () => { - api.show.layer(api.var.layer_max,0); - } - - ui.slider.onmouseover = (ev) => { - api.event.emit('slider.label'); - }; - - ui.slider.onmouseleave = (ev) => { - if (!ev.buttons) api.event.emit('slider.unlabel'); - }; - - api.event.on('slider.unlabel', (values) => { - }); - - api.event.on('slider.label', (values) => { - let digits = api.var.layer_max.toString().length; - $('slider-zero').style.width = `${digits}em`; - $('slider-max').style.width = `${digits}em`; - $('slider-zero').innerText = api.var.layer_lo; - $('slider-max').innerText = api.var.layer_hi; - }); - - api.event.on('slider.set', (values) => { - let width = slider.clientWidth; - let maxval = width - slbar2; - let start = Math.max(0, Math.min(1, values.start)); - let end = Math.max(start, Math.min(1, values.end)); - let lowval = start * maxval; - let midval = ((end - start) * maxval) + slbar; - let hival = maxval - end * maxval; - ui.sliderHold.style.marginLeft = `${lowval}px`; - ui.sliderMid.style.width = `${midval}px`; - ui.sliderHold.style.marginRight = `${hival}px`; - }); - - // 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 language choices - $('lset-en').onclick = function() { - sdb.setItem('kiri-lang', 'en-us'); - api.space.reload(); - }; - $('lset-da').onclick = function() { - sdb.setItem('kiri-lang', 'da-dk'); - api.space.reload(); - }; - $('lset-de').onclick = function() { - sdb.setItem('kiri-lang', 'de-de'); - api.space.reload(); - }; - $('lset-fr').onclick = function() { - sdb.setItem('kiri-lang', 'fr-fr'); - api.space.reload(); - }; - $('lset-pl').onclick = function() { - sdb.setItem('kiri-lang', 'pl-pl'); - api.space.reload(); - }; - $('lset-pt').onclick = function() { - sdb.setItem('kiri-lang', 'pt-pt'); - api.space.reload(); - }; - $('lset-es').onclick = function() { - sdb.setItem('kiri-lang', 'es-es'); - api.space.reload(); - }; - $('lset-zh').onclick = function() { - sdb.setItem('kiri-lang', 'zh'); - api.space.reload(); - }; - - space.event.addHandlers(self, [ - 'keyup', keyUpHandler, - 'keydown', keyDownHandler, - 'keypress', keyHandler, - 'dragover', dragOverHandler, - 'dragleave', dragLeave, - 'drop', dropHandler - ]); - - function selectionSize(e) { - let dv = parseFloat(e.target.value || 1), - pv = parseFloat(e.target.was || 1), - ra = dv / pv, - xv = parseFloat(ui.sizeX.was ?? ui.scaleX.value) || 1, - yv = parseFloat(ui.sizeY.was ?? ui.scaleY.value) || 1, - zv = parseFloat(ui.sizeZ.was ?? ui.scaleZ.value) || 1, - ta = e.target, - xc = ui.lockX.checked, - yc = ui.lockY.checked, - zc = ui.lockZ.checked, - xt = ta === ui.sizeX, - yt = ta === ui.sizeY, - zt = ta === ui.sizeZ, - 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.sizeX.was = ui.sizeX.value = xv * xr; - ui.sizeY.was = ui.sizeY.value = yv * yr; - ui.sizeZ.was = ui.sizeZ.value = zv * zr; - } - - function selectionScale(e) { - let dv = parseFloat(e.target.value || 1), - pv = parseFloat(e.target.was || 1), - ra = dv / pv, - xv = parseFloat(ui.scaleX.was ?? ui.scaleX.value) || 1, - yv = parseFloat(ui.scaleY.was ?? ui.scaleY.value) || 1, - zv = parseFloat(ui.scaleZ.was ?? ui.scaleY.value) || 1, - ta = e.target, - xc = ui.lockX.checked, - yc = ui.lockY.checked, - zc = ui.lockZ.checked, - xt = ta === ui.scaleX, - yt = ta === ui.scaleY, - zt = ta === ui.scaleZ, - 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.scaleX.was = ui.scaleX.value = xv * xr; - ui.scaleY.was = ui.scaleY.value = yv * yr; - ui.scaleZ.was = ui.scaleZ.value = zv * zr; - } - - function selectionRotate(e) { - let val = parseFloat(e.target.value) || 0; - e.target.value = val; - } - - // on enter but not on blur - space.event.onEnterKey([ - ui.scaleX, selectionScale, - ui.scaleY, selectionScale, - ui.scaleZ, selectionScale, - ui.sizeX, selectionSize, - ui.sizeY, selectionSize, - ui.sizeZ, selectionSize, - ]); - // 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'), selectionRotate, - $('rot_y'), selectionRotate, - $('rot_z'), selectionRotate - ], true); - - $('lab-axis').onclick = () => { - ui.lockX.checked = - ui.lockY.checked = - ui.lockZ.checked = !( - ui.lockX.checked || - ui.lockY.checked || - ui.lockZ.checked - ); - }; - - $('scale-reset').onclick = $('lab-scale').onclick = () => { - selection.scale(1 / ui.scaleX.was, 1 / ui.scaleY.was, 1 / ui.scaleZ.was); - ui.scaleX.value = ui.scaleY.value = ui.scaleZ.value = - ui.scaleX.was = ui.scaleY.was = ui.scaleZ.was = 1; - }; - - $('app-xpnd').onclick = () => { - try { - DOC.body.requestFullscreen(); - } catch (e) { - event.emit('resize'); - moto.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(); - - function mksvg(src) { - let svg = DOC.createElement('svg'); - svg.innerHTML = src; - return svg; - } - - function mklbl(src) { - let lbl = DOC.createElement('label'); - lbl.innerText = src; - return lbl; - } - - api.platform.update_size(); - - function mouseOnHover(int, event, ints) { - if (!api.feature.hover) return; - if (!int) return api.feature.hovers || api.widgets.meshes(); - api.event.emit('mouse.hover', {int, ints, event, point: int.point, type: 'widget'}); - } - - function platformOnHover(int, event) { - if (!api.feature.hover) return; - if (int) api.event.emit('mouse.hover', {point: int, event, type: 'platform'}); - } - - api.event.on("feature.hover", enable => { - space.mouse.onHover(enable ? mouseOnHover : undefined); - space.platform.onHover(enable ? platformOnHover : undefined); - }); - - // 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; - } - }; - - space.mouse.downSelect((int, event) => { - if (api.feature.on_mouse_down) { - if (int) { - api.feature.on_mouse_down(int, event); - return; - } - } - if (api.feature.hover) { - if (int) { - return api.event.emit('mouse.hover.down', {int, point: int.point}); - } else { - return selection.meshes(); - } - } - // lay flat with meta or ctrl clicking a selected face - if (int && (event.ctrlKey || event.metaKey || 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)); - selection.rotate(q); - } - if (api.view.get() !== VIEWS.ARRANGE) { - // return no selection in modes other than arrange - return null; - } else { - // return selected meshes for further mouse processing - return api.feature.hovers || selection.meshes(); - } - }); - - space.mouse.upSelect((object, event) => { - if (api.feature.on_mouse_up) { - if (event && object) { - return api.feature.on_mouse_up(object, event); - } else { - return api.widgets.meshes(); - } - } - if (event && api.feature.hover) { - api.event.emit('mouse.hover.up', { object, event }); - return; - } - if (event && event.target.nodeName === "CANVAS") { - if (object && object.object) { - if (object.object.widget) { - platform.select(object.object.widget, event.shiftKey, false); - } - } else { - platform.deselect(); - } - } else { - return api.feature.hovers || api.widgets.meshes(); - } - }); - - space.mouse.onDrag(function(delta, offset, up = false) { - if (api.feature.hover) { - return; - } - if (up) { - api.event.emit('mouse.drag.done', offset); - } - if (delta && ui.freeLayout.checked) { - let set = 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; - } - selection.move(delta.x, delta.y, 0); - api.event.emit('selection.drag', delta); - } else { - return selection.meshes().length > 0; - } - }); - - api.space.restore(init_two) || checkSeed(init_two) || init_two(); -}; - -// SECOND STAGE INIT AFTER UI RESTORED -function init_two() { - 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}); - } - - // 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 = function() { - const current = settings(); - const control = current.controller; - - if (!control.devel) { - // TODO: hide thin type 3 during development - api.const.LISTS.thin.length = 3; - } - - platform.deselect(); - catalog.addFileListener(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; - - lineTypeSave(); - detailSave(); - updateStats(); - - // 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 - 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); - }; - - 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); - }; - - // enable modal hiding - $('mod-x').onclick = api.modal.hide; - - 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); - }); - - // dismiss gdpr alert - $('gotit').onclick = () => { - $('gdpr').style.display = 'none'; - sdb.gdpr = Date.now(); - }; - - // bind interface action elements - $('export-support-a').onclick = (ev) => { ev.stopPropagation(); api.modal.show('don8') }; - $('mode-device').onclick = api.show.devices; - $('mode-profile').onclick = settingsLoad; - $('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') }; - 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(); profileExport() }; - ui.acct.export.title = LANG.acct_xpo; - $('file-new').onclick = (ev) => { ev.stopPropagation(); workspaceNew() }; - $('file-recent').onclick = () => { api.modal.show('files') }; - $('file-import').onclick = (ev) => { api.event.import(ev); }; - 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() }; - $('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.edges({ toggle: true }); api.conf.save() }; - $('render-ghost').onclick = () => { api.view.wireframe(false, 0, api.view.is_arrange() ? 0.4 : 0.25); }; - $('render-wire').onclick = () => { api.view.wireframe(true, 0, api.space.is_dark() ? 0.25 : 0.5); }; - $('render-solid').onclick = () => { api.view.wireframe(false, 0, 1); }; - $('mesh-export-stl').onclick = () => { objectsExport('stl') }; - $('mesh-export-obj').onclick = () => { objectsExport('obj') }; - $('mesh-merge').onclick = selection.merge; - $('mesh-split').onclick = selection.isolateBodies; - $('context-duplicate').onclick = duplicateSelection; - $('context-mirror').onclick = mirrorSelection; - $('context-layflat').onclick = () => { api.event.emit("tool.mesh.lay-flat") }; - $('context-lefty').onclick = () => { api.event.emit("tool.mesh.lefty") }; - $('context-setfocus').onclick = () => { - api.event.emit( - "tool.camera.focus", - ev => api.space.set_focus(undefined, ev.object.point) - ); - }; - $('context-contents').onclick = () => { api.SPACE.view.fit() }; - $('view-fit').onclick = () => { api.SPACE.view.fit() }; - $('wassup').onmouseover = () => { $('suppopp').classList.remove('hide') }; - - // ui.modal.onclick = api.modal.hide; - ui.modalBox.onclick = (ev) => { ev.stopPropagation() }; - - // add app name hover info - $('app-info').innerText = version; - - // 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("CAUTION"); - api.show.alert("this is a development release"); - api.show.alert("and may not function properly"); - sdb.kiri_beta = beta; - } - - // 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'; -} - -// update static html elements with language overrides -function lang_rewrite() { - // 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 -function init_lang() { - // 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(); - init_one(); - }; - scr.onerror = function(err) { - console.log({language_load_error: err, lang}) - api.language.set(); - lang_rewrite(); - init_one(); - } - } else { - // set to browser default which will be overridden - // by any future script loads (above) - api.language.set(); - lang_rewrite(); - init_one(); - } -} - -export function run() { - client.start(); - init_lang(); - return api; -} diff --git a/src/kiri/core/main.js b/src/kiri/core/main.js deleted file mode 100644 index 279aed3b..00000000 --- a/src/kiri/core/main.js +++ /dev/null @@ -1,918 +0,0 @@ -/** Copyright Stewart Allen -- 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(``); - }); - 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
Consider resizing under 1000 x 1000
Proceed with import?").then(ok => { - if (ok) { - loadImageDialog(image, name, true); - } - }); - } - const opt = {pre: [ - "
", - "

Image Conversion

", - "

", - " 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.", - "

", - "
", - " ", - " ", - " ", - " ", - " ", - " ", - "
blur value invert image
base size invert alpha
border size
", - "
" - ]}; - 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 = ``; - - 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 = ''; - del.onclick = deleteSettings; - - edit.setAttribute('name', sk); - edit.setAttribute('title', 'edit'); - edit.innerHTML = ''; - edit.onclick = editSettings; - - xprt.setAttribute('name', sk); - xprt.setAttribute('title', 'export'); - xprt.innerHTML = ''; - 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 = `x`; - $('_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 }; diff --git a/src/kiri/core/print.js b/src/kiri/core/print.js index 11419606..a0d59783 100644 --- a/src/kiri/core/print.js +++ b/src/kiri/core/print.js @@ -1,14 +1,14 @@ /** Copyright Stewart Allen -- 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"); } diff --git a/src/kiri/core/render.js b/src/kiri/core/render.js index 7dfb7e97..66e39b82 100644 --- a/src/kiri/core/render.js +++ b/src/kiri/core/render.js @@ -1,6 +1,6 @@ /** Copyright Stewart Allen -- 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'; diff --git a/src/kiri/core/selection.js b/src/kiri/core/selection.js deleted file mode 100644 index 6ef28db1..00000000 --- a/src/kiri/core/selection.js +++ /dev/null @@ -1,354 +0,0 @@ -/** Copyright Stewart Allen -- 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 { - 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 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() }, -}; diff --git a/src/kiri/core/slice.js b/src/kiri/core/slice.js index d684d046..606692a4 100644 --- a/src/kiri/core/slice.js +++ b/src/kiri/core/slice.js @@ -1,6 +1,6 @@ /** Copyright Stewart Allen -- 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; diff --git a/src/kiri/core/widget.js b/src/kiri/core/widget.js index 24e475cb..c518ac29 100644 --- a/src/kiri/core/widget.js +++ b/src/kiri/core/widget.js @@ -1,7 +1,6 @@ /** Copyright Stewart Allen -- 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 }; diff --git a/src/kiri/dev/fdm/Creality.Ender.7 b/src/kiri/dev/fdm/Creality.Ender.7.json similarity index 100% rename from src/kiri/dev/fdm/Creality.Ender.7 rename to src/kiri/dev/fdm/Creality.Ender.7.json diff --git a/src/kiri/dev/fdm/GridBot.One.json b/src/kiri/dev/fdm/GridBot.One.json deleted file mode 100644 index 21ccb365..00000000 --- a/src/kiri/dev/fdm/GridBot.One.json +++ /dev/null @@ -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 - } -} diff --git a/src/kiri/dev/fdm/GridBot.Two.json b/src/kiri/dev/fdm/GridBot.Two.json deleted file mode 100644 index 21ccb365..00000000 --- a/src/kiri/dev/fdm/GridBot.Two.json +++ /dev/null @@ -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 - } -} diff --git a/src/kiri/dev/fdm/Snapmaker.U1.json b/src/kiri/dev/fdm/Snapmaker.U1.json new file mode 100644 index 00000000..653fd140 --- /dev/null +++ b/src/kiri/dev/fdm/Snapmaker.U1.json @@ -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 <-----------------------<" + ] +} \ No newline at end of file diff --git a/src/kiri/mode/cam/anim-2d-fe.js b/src/kiri/mode/cam/app/anim-2d.js similarity index 98% rename from src/kiri/mode/cam/anim-2d-fe.js rename to src/kiri/mode/cam/app/anim-2d.js index 5e13d220..5d3312e5 100644 --- a/src/kiri/mode/cam/anim-2d-fe.js +++ b/src/kiri/mode/cam/app/anim-2d.js @@ -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; diff --git a/src/kiri/mode/cam/anim-3d-fe.js b/src/kiri/mode/cam/app/anim-3d.js similarity index 99% rename from src/kiri/mode/cam/anim-3d-fe.js rename to src/kiri/mode/cam/app/anim-3d.js index 5c36e2e8..8e26a1ac 100644 --- a/src/kiri/mode/cam/anim-3d-fe.js +++ b/src/kiri/mode/cam/app/anim-3d.js @@ -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; diff --git a/src/kiri/mode/cam/cl-flip.js b/src/kiri/mode/cam/app/cl-flip.js similarity index 94% rename from src/kiri/mode/cam/cl-flip.js rename to src/kiri/mode/cam/app/cl-flip.js index 166d8b4a..2b11cdf2 100644 --- a/src/kiri/mode/cam/cl-flip.js +++ b/src/kiri/mode/cam/app/cl-flip.js @@ -1,8 +1,8 @@ /** Copyright Stewart Allen -- 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'; diff --git a/src/kiri/mode/cam/cl-helical.js b/src/kiri/mode/cam/app/cl-helical.js similarity index 93% rename from src/kiri/mode/cam/cl-helical.js rename to src/kiri/mode/cam/app/cl-helical.js index bf64098e..4472efe4 100644 --- a/src/kiri/mode/cam/cl-helical.js +++ b/src/kiri/mode/cam/app/cl-helical.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; diff --git a/src/kiri/mode/cam/cl-hole.js b/src/kiri/mode/cam/app/cl-hole.js similarity index 98% rename from src/kiri/mode/cam/cl-hole.js rename to src/kiri/mode/cam/app/cl-hole.js index 5a2125ed..46b2ab8f 100644 --- a/src/kiri/mode/cam/cl-hole.js +++ b/src/kiri/mode/cam/app/cl-hole.js @@ -1,8 +1,8 @@ /** Copyright Stewart Allen -- 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; diff --git a/src/kiri/mode/cam/cl-ops.js b/src/kiri/mode/cam/app/cl-ops.js similarity index 86% rename from src/kiri/mode/cam/cl-ops.js rename to src/kiri/mode/cam/app/cl-ops.js index 68d2e971..9fe753cf 100644 --- a/src/kiri/mode/cam/cl-ops.js +++ b/src/kiri/mode/cam/app/cl-ops.js @@ -1,14 +1,16 @@ /** Copyright Stewart Allen -- 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: '' }), + ], { 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(), }; diff --git a/src/kiri/mode/cam/cl-origin.js b/src/kiri/mode/cam/app/cl-origin.js similarity index 95% rename from src/kiri/mode/cam/cl-origin.js rename to src/kiri/mode/cam/app/cl-origin.js index 73fca4d9..9ebc6efc 100644 --- a/src/kiri/mode/cam/cl-origin.js +++ b/src/kiri/mode/cam/app/cl-origin.js @@ -1,13 +1,13 @@ /** Copyright Stewart Allen -- 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() { diff --git a/src/kiri/mode/cam/cl-stock.js b/src/kiri/mode/cam/app/cl-stock.js similarity index 64% rename from src/kiri/mode/cam/cl-stock.js rename to src/kiri/mode/cam/app/cl-stock.js index dbb1777d..550fdfb4 100644 --- a/src/kiri/mode/cam/cl-stock.js +++ b/src/kiri/mode/cam/app/cl-stock.js @@ -1,13 +1,77 @@ /** Copyright Stewart Allen -- 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(); } diff --git a/src/kiri/mode/cam/cl-surface.js b/src/kiri/mode/cam/app/cl-surface.js similarity index 93% rename from src/kiri/mode/cam/cl-surface.js rename to src/kiri/mode/cam/app/cl-surface.js index baf6a2cc..8560b452 100644 --- a/src/kiri/mode/cam/cl-surface.js +++ b/src/kiri/mode/cam/app/cl-surface.js @@ -1,8 +1,8 @@ /** Copyright Stewart Allen -- 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; diff --git a/src/kiri/mode/cam/cl-tab.js b/src/kiri/mode/cam/app/cl-tab.js similarity index 90% rename from src/kiri/mode/cam/cl-tab.js rename to src/kiri/mode/cam/app/cl-tab.js index 9abcd4af..3306436b 100644 --- a/src/kiri/mode/cam/cl-tab.js +++ b/src/kiri/mode/cam/app/cl-tab.js @@ -1,10 +1,11 @@ /** Copyright Stewart Allen -- 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); } diff --git a/src/kiri/mode/cam/app/cl-trace.js b/src/kiri/mode/cam/app/cl-trace.js new file mode 100644 index 00000000..45ca81d0 --- /dev/null +++ b/src/kiri/mode/cam/app/cl-trace.js @@ -0,0 +1,230 @@ +/** Copyright Stewart Allen -- 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); + } + }); + } +} diff --git a/src/kiri/mode/cam/app/cl-zplane.js b/src/kiri/mode/cam/app/cl-zplane.js new file mode 100644 index 00000000..890403f9 --- /dev/null +++ b/src/kiri/mode/cam/app/cl-zplane.js @@ -0,0 +1,41 @@ +/** Copyright Stewart Allen -- 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(); + } +} diff --git a/src/kiri/mode/cam/driver-fe.js b/src/kiri/mode/cam/app/dispatch.js similarity index 97% rename from src/kiri/mode/cam/driver-fe.js rename to src/kiri/mode/cam/app/dispatch.js index ed3d1c46..cbfc2177 100644 --- a/src/kiri/mode/cam/driver-fe.js +++ b/src/kiri/mode/cam/app/dispatch.js @@ -1,7 +1,7 @@ /** Copyright Stewart Allen -- 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(); diff --git a/src/kiri/mode/cam/init-menu.js b/src/kiri/mode/cam/app/init-menu.js similarity index 90% rename from src/kiri/mode/cam/init-menu.js rename to src/kiri/mode/cam/app/init-menu.js index f179aa7b..6f59d4bc 100644 --- a/src/kiri/mode/cam/init-menu.js +++ b/src/kiri/mode/cam/app/init-menu.js @@ -1,6 +1,6 @@ /** Copyright Stewart Allen -- 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}), diff --git a/src/kiri/mode/cam/client.js b/src/kiri/mode/cam/app/init-ui.js similarity index 88% rename from src/kiri/mode/cam/client.js rename to src/kiri/mode/cam/app/init-ui.js index 5b9cea05..2f315981 100644 --- a/src/kiri/mode/cam/client.js +++ b/src/kiri/mode/cam/app/init-ui.js @@ -1,24 +1,25 @@ /** Copyright Stewart Allen -- 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", () => { diff --git a/src/kiri/mode/cam/tools.js b/src/kiri/mode/cam/app/tools.js similarity index 97% rename from src/kiri/mode/cam/tools.js rename to src/kiri/mode/cam/app/tools.js index 16dc117e..eec36c20 100644 --- a/src/kiri/mode/cam/tools.js +++ b/src/kiri/mode/cam/app/tools.js @@ -1,10 +1,10 @@ /** Copyright Stewart Allen -- All Rights Reserved */ -import { $ } from '../../../moto/webui.js'; -import { api } from '../../core/api.js'; -import { consts } from '../../core/consts.js'; -import { settings as setconf } from '../../core/settings.js'; -import { Tool, calcTaperAngle, calcTaperBallExtent, calcTaperLength } from './tool.js'; +import { $ } from '../../../../moto/webui.js'; +import { api } from '../../../app/api.js'; +import { consts } from '../../../app/consts.js'; +import { settings as setconf } from '../../../app/conf/manager.js'; +import { Tool, calcTaperAngle, calcTaperBallExtent, calcTaperLength } from '../core/tool.js'; const DEG2RAD = Math.PI / 180; diff --git a/src/kiri/mode/cam/cl-trace.js b/src/kiri/mode/cam/cl-trace.js deleted file mode 100644 index 3af4e19d..00000000 --- a/src/kiri/mode/cam/cl-trace.js +++ /dev/null @@ -1,181 +0,0 @@ -/** Copyright Stewart Allen -- All Rights Reserved */ - -import { api } from '../../core/api.js'; -import { env, clearPops, isDark } from './client.js'; -import { CAM } from './driver-fe.js'; -import { Layers } from '../../core/layers.js'; -import { Stack } from '../../core/stack.js'; - -export let traceOn = false; -export let lastTrace; - -export function setLastTrace(trace) { - lastTrace = trace; -} - -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; - CAM.traces((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); - } - }); - }); - }, 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); - } - }); - } -} diff --git a/src/kiri/mode/cam/op.js b/src/kiri/mode/cam/core/op.js similarity index 100% rename from src/kiri/mode/cam/op.js rename to src/kiri/mode/cam/core/op.js diff --git a/src/kiri/mode/cam/core/ops.js b/src/kiri/mode/cam/core/ops.js new file mode 100644 index 00000000..bfb6e1d4 --- /dev/null +++ b/src/kiri/mode/cam/core/ops.js @@ -0,0 +1,43 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { CamOp } from './op.js'; +import { OpArea } from '../work/op-area.js'; +import { OpContour } from '../work/op-contour.js'; +import { OpDrill } from '../work/op-drill.js'; +import { OpGCode } from '../work/op-gcode.js'; +import { OpHelical } from '../work/op-helical.js'; +import { OpIndex } from '../work/op-index.js'; +import { OpLaserOff } from '../work/op-laser-off.js'; +import { OpLaserOn } from '../work/op-laser-on.js'; +import { OpLathe } from '../work/op-lathe.js'; +import { OpLevel } from '../work/op-level.js'; +import { OpLoop } from '../work/op-loop.js'; +import { OpOutline } from '../work/op-outline.js'; +import { OpPocket } from '../work/op-pocket.js'; +import { OpRegister } from '../work/op-register.js'; +import { OpRough } from '../work/op-rough.js'; +import { OpShadow } from '../work/op-shadow.js'; +import { OpTrace } from '../work/op-trace.js'; +import { OpXRay } from '../work/op-xray.js'; + +export const ops = { + "area": OpArea, + "contour": OpContour, + "drill": OpDrill, + "flip": CamOp, + "gcode": OpGCode, + "helical": OpHelical, + "index": OpIndex, + "laser off": OpLaserOff, + "laser on": OpLaserOn, + "lathe": OpLathe, + "level": OpLevel, + "loop": OpLoop, + "outline": OpOutline, + "pocket": OpPocket, + "register": OpRegister, + "rough": OpRough, + "shadow": OpShadow, + "trace": OpTrace, + "xray": OpXRay +}; diff --git a/src/kiri/mode/cam/tool.js b/src/kiri/mode/cam/core/tool.js similarity index 100% rename from src/kiri/mode/cam/tool.js rename to src/kiri/mode/cam/core/tool.js diff --git a/src/kiri/mode/cam/ops.js b/src/kiri/mode/cam/ops.js deleted file mode 100644 index 79e4c7f0..00000000 --- a/src/kiri/mode/cam/ops.js +++ /dev/null @@ -1,41 +0,0 @@ -/** Copyright Stewart Allen -- All Rights Reserved */ - -import { CamOp } from './op.js'; -import { OpArea } from './op-area.js'; -import { OpContour } from './op-contour.js'; -import { OpDrill } from './op-drill.js'; -import { OpGCode } from './op-gcode.js'; -import { OpHelical } from './op-helical.js'; -import { OpIndex } from './op-index.js'; -import { OpLaserOff } from './op-laser-off.js'; -import { OpLaserOn } from './op-laser-on.js'; -import { OpLathe } from './op-lathe.js'; -import { OpLevel } from './op-level.js'; -import { OpOutline } from './op-outline.js'; -import { OpPocket } from './op-pocket.js'; -import { OpRegister } from './op-register.js'; -import { OpRough } from './op-rough.js'; -import { OpShadow } from './op-shadow.js'; -import { OpTrace } from './op-trace.js'; -import { OpXRay } from './op-xray.js'; - -export const ops = { - "area": OpArea, - "contour": OpContour, - "drill": OpDrill, - "flip": CamOp, - "gcode": OpGCode, - "helical": OpHelical, - "index": OpIndex, - "laser off": OpLaserOff, - "laser on": OpLaserOn, - "lathe": OpLathe, - "level": OpLevel, - "outline": OpOutline, - "pocket": OpPocket, - "register": OpRegister, - "rough": OpRough, - "shadow": OpShadow, - "trace": OpTrace, - "xray": OpXRay -}; diff --git a/src/kiri/mode/cam/anim-2d-be.js b/src/kiri/mode/cam/work/anim-2d.js similarity index 99% rename from src/kiri/mode/cam/anim-2d-be.js rename to src/kiri/mode/cam/work/anim-2d.js index 92c0f6e0..09255c97 100644 --- a/src/kiri/mode/cam/anim-2d-be.js +++ b/src/kiri/mode/cam/work/anim-2d.js @@ -2,7 +2,7 @@ // WORKER BACK END ANIMATION CODE for 2D -import { Tool } from './tool.js'; +import { Tool } from '../core/tool.js'; const asPoints = false; const asLines = false; diff --git a/src/kiri/mode/cam/anim-3d-be.js b/src/kiri/mode/cam/work/anim-3d.js similarity index 99% rename from src/kiri/mode/cam/anim-3d-be.js rename to src/kiri/mode/cam/work/anim-3d.js index b8c23ff7..1c8a6a03 100644 --- a/src/kiri/mode/cam/anim-3d-be.js +++ b/src/kiri/mode/cam/work/anim-3d.js @@ -2,8 +2,8 @@ // WORKER BACK END ANIMATION CODE for 3D (indexed) -import { CSG } from '../../../geo/csg.js'; -import { Tool } from './tool.js'; +import { CSG } from '../../../../geo/csg.js'; +import { Tool } from '../core/tool.js'; let nextMeshID = 1, stock, diff --git a/src/kiri/mode/cam/export.js b/src/kiri/mode/cam/work/export.js similarity index 99% rename from src/kiri/mode/cam/export.js rename to src/kiri/mode/cam/work/export.js index 7dfb657c..f8bd0b2a 100644 --- a/src/kiri/mode/cam/export.js +++ b/src/kiri/mode/cam/work/export.js @@ -1,7 +1,7 @@ /** Copyright Stewart Allen -- All Rights Reserved */ -import { base } from '../../../geo/base.js'; -import { version } from '../../../moto/license.js'; +import { base } from '../../../../geo/base.js'; +import { version } from '../../../../moto/license.js'; const { util } = base; @@ -416,6 +416,7 @@ export function cam_export(print, online) { const isOffset = offset && (offset.x || offset.y || offset.z); // remap points as necessary for origins, offsets, inversions print.output.forEach(layer => { + // console.log(layer); if (!Array.isArray(layer)) { return; } diff --git a/src/kiri/mode/cam/driver-be.js b/src/kiri/mode/cam/work/init-work.js similarity index 87% rename from src/kiri/mode/cam/driver-be.js rename to src/kiri/mode/cam/work/init-work.js index d4d6b39c..c57b1971 100644 --- a/src/kiri/mode/cam/driver-be.js +++ b/src/kiri/mode/cam/work/init-work.js @@ -1,14 +1,14 @@ /** Copyright Stewart Allen -- All Rights Reserved */ -import { codec } from '../../core/codec.js'; -import { cam_slice, holes, traces } from './slice.js'; +import { codec } from '../../../core/codec.js'; +import { cam_slice_pre, cam_slice, holes, traces } from './slice.js'; import { cam_prepare } from './prepare.js'; import { cam_export } from './export.js'; -import { tool as mesh_tool } from '../../../mesh/tool.js'; +import { tool as mesh_tool } from '../../../../mesh/tool.js'; import { cylinder_poly_find } from './slice.js'; -import { init as init_2d } from './anim-2d-be.js'; -import { init as init_3d } from './anim-3d-be.js'; +import { init as init_2d } from './anim-2d.js'; +import { init as init_3d } from './anim-3d.js'; function surface_prep(widget, index) { if (!widget.tool) { @@ -18,9 +18,9 @@ function surface_prep(widget, index) { } }; -function surface_find(widget, faces, radians) { +function surface_find(widget, faces, radians, filterZ) { surface_prep(widget); - return widget.tool.findConnectedSurface(faces, radians || 0, 0.0); + return widget.tool.findConnectedSurface(faces, radians, filterZ); }; function init(worker) { @@ -114,7 +114,9 @@ export const CAM = { init, surface_prep, surface_find, + slicePre: cam_slice_pre, slice: cam_slice, + slicePost: undefined, prepare: cam_prepare, export: cam_export }; diff --git a/src/kiri/mode/cam/op-area.js b/src/kiri/mode/cam/work/op-area.js similarity index 84% rename from src/kiri/mode/cam/op-area.js rename to src/kiri/mode/cam/work/op-area.js index 35c518e4..03a4a9fe 100644 --- a/src/kiri/mode/cam/op-area.js +++ b/src/kiri/mode/cam/work/op-area.js @@ -3,14 +3,15 @@ // todo: surface offset pattern // todo: trace dogbones, merge overlap -import { CamOp } from './op.js'; -import { Tool } from './tool.js'; -import { newSlice } from '../../core/slice.js'; -import { newPoint } from '../../../geo/point.js'; -import { newPolygon } from '../../../geo/polygon.js'; -import { polygons as POLY } from '../../../geo/polygons.js'; -import { util as base_util } from '../../../geo/base.js'; -import { CAM } from './driver-be.js'; +import { CamOp } from '../core/op.js'; +import { Tool } from '../core/tool.js'; +import { newSlice } from '../../../core/slice.js'; +import { newPoint } from '../../../../geo/point.js'; +import { newPolygon } from '../../../../geo/polygon.js'; +import { polygons as POLY } from '../../../../geo/polygons.js'; +import { util as base_util } from '../../../../geo/base.js'; +import { tip2tipJoin } from '../../../../geo/paths.js'; +import { CAM } from './init-work.js'; const DEG2RAD = Math.PI / 180; const clib = self.ClipperLib; @@ -28,19 +29,24 @@ class OpArea extends CamOp { let { op, state } = this; let { direction, down, expand, flats, flatOff, follow } = op; let { mode, outline, over, rename, smooth, tool } = op; - let { addSlices, color, cutTabs, settings } = state; + let { addSlices, axisIndex, color, cutTabs, settings } = state; let { shadowAt, setToolDiam, tabs, widget, workarea } = state; let areaTool = new Tool(settings, tool); let smoothVal = (smooth ?? 0) / 10; let toolDiam = areaTool.fluteDiameter(); - let toolOver = areaTool.getStepSize(over); + let toolOver = this.toolOver = areaTool.getStepSize(over); let zTop = workarea.top_z; let zBottom = workarea.bottom_z; let shadowBase = state.shadow.base; let thruHoles = state.shadow.holes; let roundSharps = settings.process.camRoundCorners; + // get these once and re-use for each area + let vertices = mode === 'surface' ? + widget.getGeoVertices({ unroll: true, translate: true }) : + undefined; + // also updates tab offsets setToolDiam(toolDiam); @@ -65,10 +71,17 @@ class OpArea extends CamOp { } // gather area selections + if (!op.shadow) for (let arr of (op.areas[widget.id] ?? [])) { let poly = newPolygon().fromArray(arr); aminz = Math.min(aminz, poly.minZ()); polys.push(poly); + // user-selected traces are not rotated + // those based on shadow (outline, rough) are + if (!op.rotated && axisIndex) { + // traces come from unrotatead widget edges + poly.applyRotations(axisIndex); + } } // connect open poly edge segments into closed loops (when possible) @@ -76,22 +89,29 @@ class OpArea extends CamOp { polys = POLY.nest(POLY.reconnect(polys, false)); // gather surface selections - let vert = widget.getGeoVertices({ unroll: true, translate: true }).map(v => v.round(4)); - let faces = CAM.surface_find(widget, (op.surfaces[widget.id] ?? []), (follow ?? edgeangle ?? 5) * DEG2RAD); - let fpoly = []; - for (let face of faces) { - let i = face * 9; - fpoly.push(newPolygon() - .add(vert[i++], vert[i++], aminz = Math.min(aminz, vert[i++])) - .add(vert[i++], vert[i++], aminz = Math.min(aminz, vert[i++])) - .add(vert[i++], vert[i++], aminz = Math.min(aminz, vert[i++])) - ); - } - // remove invalid edges (eg. when vertical walls are the only selection) - fpoly = fpoly.filter(p => p.area() > 0.001); + if (!op.shadow) { + let vert = widget.getGeoVertices({ unroll: true, translate: true }).map(v => v.round(4)); + let faces = CAM.surface_find(widget, (op.surfaces[widget.id] ?? []), (follow ?? edgeangle ?? 5) * DEG2RAD); + let fpoly = []; + for (let face of faces) { + let i = face * 9; + fpoly.push(newPolygon() + .add(vert[i++], vert[i++], aminz = Math.min(aminz, vert[i++])) + .add(vert[i++], vert[i++], aminz = Math.min(aminz, vert[i++])) + .add(vert[i++], vert[i++], aminz = Math.min(aminz, vert[i++])) + ); + } + // remove invalid edges (eg. when vertical walls are the only selection) + fpoly = fpoly.filter(p => p.area() > 0.001); - // add in unioned surface areas - polys.push(...POLY.setZ(POLY.union(fpoly, 0.00001, true), aminz)); + // add in unioned surface areas + polys.push(...POLY.setZ(POLY.union(fpoly, 0.00001, true), aminz)); + } + + // use part shadow instead of areas or surfaces + if (op.shadow) { + polys.push(...shadowBase.clone(true)); + } // smoothing for jaggies usually caused by vertical walls if (smoothVal) { @@ -149,6 +169,8 @@ class OpArea extends CamOp { let zinc = 1 / zs.length; let lzo; + if (!zs.length) break; + outer: for (;;) for (let z of zs) { let slice = newLayer(z); @@ -168,7 +190,7 @@ class OpArea extends CamOp { let firstOff = -(toolDiam / 2 + (op.leave_xy ?? 0)); POLY.subtract([ area ], shadow, clip, undefined, undefined, 0); POLY.offset(clip, [ firstOff, -toolOver ], { - count: op.steps ?? 999, outs, flat: true, z: z - zMov, ...offopt + count: op.walls ? 1 : (op.steps ?? 999), outs, flat: true, z: z - zMov, ...offopt }); // if we see no offsets, re-check the mesh bottom Z then exit if (outs.length === 0) { @@ -300,9 +322,14 @@ class OpArea extends CamOp { } proc += pinc; progress(proc, 'trace'); + // legacy outline early termination b/c use of + // shadow will cause duplicate output with area + if (tr_type === 'outside' && op.drape) { + break; + } } else if (mode === 'surface') { - let { sr_type, sr_angle, tolerance } = op; + let { sr_type, sr_angle, sr_alter, tolerance } = op; let resolution = tolerance || 0.05; let raster = await self.get_raster_gpu({ mode: "tracing", resolution }); @@ -327,8 +354,14 @@ class OpArea extends CamOp { paths.push(POLY.fromClipperNode(node, 0)); } } - // convert resulting poly lines to raster float32 array groups - paths = paths.map(poly => poly.points.map(p => [ p.x, p.y ]).flat().toFloat32()); + // in surfacing mode, direction is simply reversal + if (direction === 'climb') { + paths.forEach(path => path.reverse()); + } + // optional alternating paths + if (paths.length && sr_alter) { + paths = tip2tipJoin(paths, paths[0].first(), toolOver * 10); + } } else if (sr_type === 'offset') { // progressive inset from perimeter @@ -336,9 +369,12 @@ class OpArea extends CamOp { count: 999, outs: paths, flat: true, z: 0, minArea: 0 }); paths.forEach(poly => poly.isClosed() && poly.push(poly.first())); - paths = paths.map(poly => poly.points.map(p => [ p.x, p.y ]).flat().toFloat32()); + POLY.setWinding(paths.filter(p => p.isClosed()), direction === 'climb'); } + // convert resulting poly lines to raster float32 array groups + paths = paths.map(poly => poly.points.map(p => [ p.x, p.y ]).flat().toFloat32()); + // prepare tool mesh points let toolBounds = new THREE.Box3() .expandByPoint({ x: -toolDiam/2, y: -toolDiam/2, z: 0 }) @@ -350,7 +386,6 @@ class OpArea extends CamOp { let toolData = { positions: toolPos, bounds: toolBounds }; // prepare terrain and raster paths over terrain - let vertices = widget.getGeoVertices({ unroll: true, translate: true }); let wbounds = bounds.clone().expandByVector({ x: toolDiam/2, y: toolDiam/2, z: 0 }); wbounds.min.z = zBottom; wbounds.max.z = zTop; @@ -369,11 +404,12 @@ class OpArea extends CamOp { paths, step: toolOver / 2, zFloor: zBottom - 1, - onProgress(pct) { console.log({ pct }); onupdate(pct/100, 100) } + onProgress: pct => progress(proc + (pinc * (pct/100))) }); raster.terminate(); // convert terrain raster output back to open polylines + // todo: add leave_z support for (let path of output.paths) { path = newPolygon().fromArray([1, ...path]); if (op.refine) path.refine(op.refine); @@ -387,6 +423,9 @@ class OpArea extends CamOp { // output this surface surfaces.push(surface); + + proc += pinc; + progress(proc); } } // filter out empty slices @@ -399,26 +438,20 @@ class OpArea extends CamOp { } prepare(ops, progress) { - let { op, state, areas, surfaces } = this; + let { op, state, areas, surfaces, toolOver } = this; let { newLayer, pocket, polyEmit, printPoint, tip2tipEmit } = ops; let { setContouring, setNextIsMove } = ops; let { process } = state.settings; // process surface paths if (surfaces.length) { - setContouring(true); + setContouring(true, toolOver * 2); for (let surface of surfaces) { - let array = surface.map(poly => { return { - el: poly, - first: poly.first(), - last: poly.last() - } }); - tip2tipEmit(array, printPoint, (next, point) => { + for (let poly of surface) { setNextIsMove(); - if (next.last === point) next.el.reverse(); - printPoint = polyEmit(next.el); + printPoint = polyEmit(poly); newLayer(); - }); + } } setContouring(false); // skip areas when processing surfaces diff --git a/src/kiri/mode/cam/op-contour.js b/src/kiri/mode/cam/work/op-contour.js similarity index 87% rename from src/kiri/mode/cam/op-contour.js rename to src/kiri/mode/cam/work/op-contour.js index baff1a70..e51ce6bd 100644 --- a/src/kiri/mode/cam/op-contour.js +++ b/src/kiri/mode/cam/work/op-contour.js @@ -1,11 +1,12 @@ /** Copyright Stewart Allen -- All Rights Reserved */ -import { CamOp } from './op.js'; -import { Tool } from './tool.js'; +import { CamOp } from '../core/op.js'; +import { Tool } from '../core/tool.js'; import { generate as Topo } from './topo3.js'; -import { newPoint } from '../../../geo/point.js'; -import { newPolygon } from '../../../geo/polygon.js'; -import { tip2tipEmit } from '../../../geo/paths.js'; +import { newPoint } from '../../../../geo/point.js'; +import { newPolygon } from '../../../../geo/polygon.js'; +import { newSlice } from '../../../core/slice.js'; +import { tip2tipEmit } from '../../../../geo/paths.js'; function createFilter(op, origin, axis) { // console.log({ origin, axis }); @@ -83,6 +84,7 @@ class OpContour extends CamOp { async slice(progress) { let { op, state } = this; let { color, addSlices, settings, updateToolDiams } = state; + let conTool = new Tool(settings, op.tool); let filter = createFilter(op, settings.origin, op.axis.toLowerCase()); let toolDiam = this.toolDiam = conTool.fluteDiameter(); @@ -93,7 +95,7 @@ class OpContour extends CamOp { // we need topo for safe travel moves when roughing and outlining // not generated when drilling-only. then all z moves use bounds max. // also generates x and y contouring when selected - let topo = await Topo({ + let topo = this.topo = await Topo({ // onupdate: (update, msg) => { onupdate: (index, total, msg) => { progress(index / total, msg); @@ -112,24 +114,31 @@ class OpContour extends CamOp { state }); + if (this.debug && topo.coastline) { + console.log('coastline', topo.coastline); + const dbs = newSlice(-1); + const dbo = dbs.output(); + dbo.setLayer("coastline", { line: 0x0000dd }).addPolys(topo.coastline); + addSlices([ dbs ]) + } + // computed if set to 0 this.tolerance = topo.tolerance; } prepare(ops, progress) { - let { op, state, sliceOut, toolStep } = this; + let { op, sliceOut, state, toolStep, topo } = this; let { settings } = state; let { process } = settings; let { polyEmit, setContouring, setTolerance, setTool } = ops; - let { widget, camOut, newLayer, zmax } = ops; + let { widget, newLayer, zmax } = ops; let bounds = widget.getBoundingBox(); - let toolDiam = this.toolDiam; let depthData = []; setTool(op.tool, op.rate, process.camFastFeedZ); - setContouring(true, toolStep * 1.5); + setContouring(true, toolStep * 1.5, topo.coastline); setTolerance(this.tolerance); let printPoint = newPoint(bounds.min.x, bounds.min.y, zmax); diff --git a/src/kiri/mode/cam/op-drill.js b/src/kiri/mode/cam/work/op-drill.js similarity index 89% rename from src/kiri/mode/cam/op-drill.js rename to src/kiri/mode/cam/work/op-drill.js index 319d9ebb..3189e6d5 100644 --- a/src/kiri/mode/cam/op-drill.js +++ b/src/kiri/mode/cam/work/op-drill.js @@ -1,10 +1,10 @@ /** Copyright Stewart Allen -- All Rights Reserved */ -import { CamOp } from './op.js'; -import { Tool } from './tool.js'; -import { newPolygon } from '../../../geo/polygon.js'; -import { newSlice } from '../../core/slice.js'; -import { newPoint } from '../../../geo/point.js'; +import { CamOp } from '../core/op.js'; +import { Tool } from '../core/tool.js'; +import { newPolygon } from '../../../../geo/polygon.js'; +import { newSlice } from '../../../core/slice.js'; +import { newPoint } from '../../../../geo/point.js'; class OpDrill extends CamOp { constructor(state, op) { diff --git a/src/kiri/mode/cam/op-gcode.js b/src/kiri/mode/cam/work/op-gcode.js similarity index 86% rename from src/kiri/mode/cam/op-gcode.js rename to src/kiri/mode/cam/work/op-gcode.js index a3ed9bfb..3881718a 100644 --- a/src/kiri/mode/cam/op-gcode.js +++ b/src/kiri/mode/cam/work/op-gcode.js @@ -1,6 +1,6 @@ /** Copyright Stewart Allen -- All Rights Reserved */ -import { CamOp } from './op.js'; +import { CamOp } from '../core/op.js'; export class OpGCode extends CamOp { constructor(state, op) { diff --git a/src/kiri/mode/cam/op-helical.js b/src/kiri/mode/cam/work/op-helical.js similarity index 97% rename from src/kiri/mode/cam/op-helical.js rename to src/kiri/mode/cam/work/op-helical.js index 3458c45b..299ae493 100644 --- a/src/kiri/mode/cam/op-helical.js +++ b/src/kiri/mode/cam/work/op-helical.js @@ -1,10 +1,10 @@ -import { arcToPath } from "../../../geo/paths.js"; -import { newPolygon } from "../../../geo/polygon.js"; -import { CamOp } from "./op.js"; -import { Tool } from "./tool.js"; +import { arcToPath } from "../../../../geo/paths.js"; +import { newPolygon } from "../../../../geo/polygon.js"; +import { CamOp } from "../core/op.js"; +import { Tool } from "../core/tool.js"; import { cylinder_poly_find } from "./slice.js"; -import { newPoint } from "../../../geo/point.js"; -import { newSlice } from "../../core/slice.js"; +import { newPoint } from "../../../../geo/point.js"; +import { newSlice } from "../../../core/slice.js"; export class OpHelical extends CamOp { constructor(state, op) { diff --git a/src/kiri/mode/cam/op-index.js b/src/kiri/mode/cam/work/op-index.js similarity index 53% rename from src/kiri/mode/cam/op-index.js rename to src/kiri/mode/cam/work/op-index.js index 21ad46a4..e8a285ae 100644 --- a/src/kiri/mode/cam/op-index.js +++ b/src/kiri/mode/cam/work/op-index.js @@ -1,27 +1,39 @@ /** Copyright Stewart Allen -- All Rights Reserved */ -import { CamOp } from './op.js'; +import { CamOp } from '../core/op.js'; export class OpIndex extends CamOp { constructor(state, op) { super(state, op); } - weight() { - return 0.1; - } - - async slice() { + async slice(progress) { let { op, state } = this; + if (!state.isIndexed) { throw 'index op requires indexed stock'; } - let { widget, updateSlicer, computeShadows, setAxisIndex } = state; - this.degrees = await setAxisIndex(op.degrees, op.absolute); + + let { lastAxisIndex, widget, updateSlicer, computeShadows, setAxisIndex } = state; + let { degrees, absolute } = op; + + if (absolute && lastAxisIndex === degrees) { + return console.log('skip redundant absolute index'); + } else if (!absolute && degrees === 0) { + return console.log('skip redundant relative index'); + } + + this.degrees = await setAxisIndex(degrees, absolute); + state.lastAxisIndex = this.degrees; + // force recompute of topo widget.topo = undefined; + + // update slicer for new widget geometry updateSlicer(); - await computeShadows(); + + // recompute shadow from new widget geometry + await computeShadows(progress); } prepare(ops, progress) { diff --git a/src/kiri/mode/cam/op-laser-off.js b/src/kiri/mode/cam/work/op-laser-off.js similarity index 91% rename from src/kiri/mode/cam/op-laser-off.js rename to src/kiri/mode/cam/work/op-laser-off.js index a474e933..fcdb8118 100644 --- a/src/kiri/mode/cam/op-laser-off.js +++ b/src/kiri/mode/cam/work/op-laser-off.js @@ -1,6 +1,6 @@ /** Copyright Stewart Allen -- All Rights Reserved */ -import { CamOp } from './op.js'; +import { CamOp } from '../core/op.js'; export class OpLaserOff extends CamOp { constructor(state, op) { diff --git a/src/kiri/mode/cam/op-laser-on.js b/src/kiri/mode/cam/work/op-laser-on.js similarity index 91% rename from src/kiri/mode/cam/op-laser-on.js rename to src/kiri/mode/cam/work/op-laser-on.js index 076009c9..f67a47ae 100644 --- a/src/kiri/mode/cam/op-laser-on.js +++ b/src/kiri/mode/cam/work/op-laser-on.js @@ -1,6 +1,6 @@ /** Copyright Stewart Allen -- All Rights Reserved */ -import { CamOp } from './op.js'; +import { CamOp } from '../core/op.js'; export class OpLaserOn extends CamOp { constructor(state, op) { diff --git a/src/kiri/mode/cam/op-lathe.js b/src/kiri/mode/cam/work/op-lathe.js similarity index 96% rename from src/kiri/mode/cam/op-lathe.js rename to src/kiri/mode/cam/work/op-lathe.js index fcc69e63..15c751d5 100644 --- a/src/kiri/mode/cam/op-lathe.js +++ b/src/kiri/mode/cam/work/op-lathe.js @@ -1,8 +1,8 @@ /** Copyright Stewart Allen -- All Rights Reserved */ -import { CamOp } from './op.js'; +import { CamOp } from '../core/op.js'; import { generate as topo4_generate } from './topo4.js'; -import { newPoint } from '../../../geo/point.js'; +import { newPoint } from '../../../../geo/point.js'; function createFilter(op) { let ok = () => true; diff --git a/src/kiri/mode/cam/op-level.js b/src/kiri/mode/cam/work/op-level.js similarity index 68% rename from src/kiri/mode/cam/op-level.js rename to src/kiri/mode/cam/work/op-level.js index 39260ba6..2f90b81e 100644 --- a/src/kiri/mode/cam/op-level.js +++ b/src/kiri/mode/cam/work/op-level.js @@ -1,11 +1,11 @@ /** Copyright Stewart Allen -- All Rights Reserved */ -import { CamOp } from './op.js'; -import { Tool } from './tool.js'; -import { newPolygon } from '../../../geo/polygon.js'; -import { polygons as POLY } from '../../../geo/polygons.js'; -import { newSlice } from '../../core/slice.js'; -import { util } from '../../../geo/base.js'; +import { CamOp } from '../core/op.js'; +import { Tool } from '../core/tool.js'; +import { newPolygon } from '../../../../geo/polygon.js'; +import { polygons as POLY } from '../../../../geo/polygons.js'; +import { newSlice } from '../../../core/slice.js'; +import { util } from '../../../../geo/base.js'; class OpLevel extends CamOp { constructor(state, op) { @@ -14,9 +14,11 @@ class OpLevel extends CamOp { async slice(progress) { let { op, state } = this; - let { addSlices, color, settings, shadow, updateToolDiams, zMax, ztOff } = state; + let { addSlices, color, settings, shadow } = state; + let { share, updateToolDiams, zMax, ztOff } = state; let { down, tool, step, stepz, inset } = op; let { stock } = settings; + let { center } = stock; let toolDiam = new Tool(settings, tool).fluteDiameter(); let stepOver = this.stepOver = toolDiam * step; @@ -25,11 +27,23 @@ class OpLevel extends CamOp { let zBot = zTop - down; let zList = stepz && down ? util.lerp(zTop, zBot, stepz) : [ zBot ]; + if (share.ran) { + console.log('skip'); + this.skip = true; + return; + } else if (op.stock) { + share.ran = true; + } + updateToolDiams(toolDiam); let points = []; let clear = op.stock ? - [ newPolygon().centerRectangle({x:-wpos.x,y:-wpos.y,z:wpos.z}, stock.x + toolDiam/2, stock.y) ] : + [ newPolygon().centerRectangle({ + x: -wpos.x + center.x, + y: -wpos.y + center.y, + z: wpos.z + center.z + }, stock.x + toolDiam/2, stock.y) ] : POLY.outer(POLY.offset(shadow.base, toolDiam * (inset || 0))); POLY.fillArea(clear, 1090, stepOver, points); @@ -50,10 +64,14 @@ class OpLevel extends CamOp { } prepare(ops, progress) { - let { layers, stepOver } = this; + let { layers, skip, stepOver } = this; let { printPoint } = ops; let { newLayer, tip2tipEmit, camOut } = ops; + if (skip) { + return; + } + for (let lines of layers) { lines = lines.map(p => { return { first: p.first(), last: p.last(), poly: p } }); printPoint = tip2tipEmit(lines, printPoint, (el, point, count) => { diff --git a/src/kiri/mode/cam/work/op-loop.js b/src/kiri/mode/cam/work/op-loop.js new file mode 100644 index 00000000..def7bc73 --- /dev/null +++ b/src/kiri/mode/cam/work/op-loop.js @@ -0,0 +1,17 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { CamOp } from '../core/op.js'; + +export class OpLoop extends CamOp { + weight() { + return 0; // No weight, acts as marker + } + + async slice() { + // No-op: expansion happens in slice.js + } + + prepare() { + // No-op: ignored during g-code generation + } +} diff --git a/src/kiri/mode/cam/op-outline.js b/src/kiri/mode/cam/work/op-outline.js similarity index 85% rename from src/kiri/mode/cam/op-outline.js rename to src/kiri/mode/cam/work/op-outline.js index 69fcf9f2..198eab31 100644 --- a/src/kiri/mode/cam/op-outline.js +++ b/src/kiri/mode/cam/work/op-outline.js @@ -1,17 +1,13 @@ /** Copyright Stewart Allen -- All Rights Reserved */ -import { CamOp } from './op.js'; +import { CamOp } from '../core/op.js'; import { OpArea } from './op-area.js'; -import { polygons as POLY } from '../../../geo/polygons.js'; -import { newPolygon } from '../../../geo/polygon.js'; class OpOutline extends CamOp { constructor(state, op) { super(state, op); } - // todo: wide cutout, dogbones - async slice(progress) { let { op, state } = this; let { direction, dogbones, down, inside, omitthru, omitvoid, outside } = op; @@ -26,7 +22,6 @@ class OpOutline extends CamOp { let ops_list = this.ops_list = [ ]; if (outside) { - // let areas = POLY.flatten(POLY.expand(shadow.base, state.tool.fluteDiameter() / 2 - 0.001)); let areas = shadow.base.clone(true); ops_list.push(new OpArea(state, { areas: { [widget.id]: areas.map(p => p.toArray()) }, @@ -37,7 +32,6 @@ class OpOutline extends CamOp { mode: 'trace', omitinner: omitvoid, omitthru, - // outline: omitthru, ov_botz, ov_topz, over: op.wide ? op.step : 0, @@ -45,6 +39,7 @@ class OpOutline extends CamOp { rate, rename: rename ?? "outline", revbones, + rotated: true, smooth: 0, spindle, steps: op.wide ? op.steps + 1 : 1, @@ -66,13 +61,13 @@ class OpOutline extends CamOp { omitinner: omitvoid, omitouter: inside, omitthru, - // outline: omitthru, ov_botz, ov_topz, plunge, rate, rename: rename ?? "outline", revbones, + rotated: true, smooth: 0, spindle, surfaces: {}, @@ -80,9 +75,6 @@ class OpOutline extends CamOp { thru: true, tr_type: 'outside', })); - if (!inside) { - // add outline - } } for (let op of this.ops_list) { diff --git a/src/kiri/mode/cam/op-pocket.js b/src/kiri/mode/cam/work/op-pocket.js similarity index 96% rename from src/kiri/mode/cam/op-pocket.js rename to src/kiri/mode/cam/work/op-pocket.js index 6bc35a2e..f225ea72 100644 --- a/src/kiri/mode/cam/op-pocket.js +++ b/src/kiri/mode/cam/work/op-pocket.js @@ -1,6 +1,6 @@ /** Copyright Stewart Allen -- All Rights Reserved */ -import { CamOp } from './op.js'; +import { CamOp } from '../core/op.js'; import { OpArea } from './op-area.js'; class OpPocket extends CamOp { diff --git a/src/kiri/mode/cam/op-register.js b/src/kiri/mode/cam/work/op-register.js similarity index 95% rename from src/kiri/mode/cam/op-register.js rename to src/kiri/mode/cam/work/op-register.js index d9c5d85c..69f7199d 100644 --- a/src/kiri/mode/cam/op-register.js +++ b/src/kiri/mode/cam/work/op-register.js @@ -1,11 +1,11 @@ /** Copyright Stewart Allen -- All Rights Reserved */ -import { CamOp } from './op.js'; -import { Tool } from './tool.js'; -import { newPolygon } from '../../../geo/polygon.js'; -import { newSlice } from '../../core/slice.js'; -import { newPoint } from '../../../geo/point.js'; -import { util } from '../../../geo/base.js'; +import { CamOp } from '../core/op.js'; +import { Tool } from '../core/tool.js'; +import { newPolygon } from '../../../../geo/polygon.js'; +import { newSlice } from '../../../core/slice.js'; +import { newPoint } from '../../../../geo/point.js'; +import { util } from '../../../../geo/base.js'; class OpRegister extends CamOp { constructor(state, op) { diff --git a/src/kiri/mode/cam/op-rough.js b/src/kiri/mode/cam/work/op-rough.js similarity index 80% rename from src/kiri/mode/cam/op-rough.js rename to src/kiri/mode/cam/work/op-rough.js index 11349a1f..fbe50235 100644 --- a/src/kiri/mode/cam/op-rough.js +++ b/src/kiri/mode/cam/work/op-rough.js @@ -1,9 +1,9 @@ /** Copyright Stewart Allen -- All Rights Reserved */ -import { CamOp } from './op.js'; +import { CamOp } from '../core/op.js'; import { OpArea } from './op-area.js'; -import { newPolygon } from '../../../geo/polygon.js'; -import { polygons as POLY } from '../../../geo/polygons.js'; +import { newPolygon } from '../../../../geo/polygon.js'; +import { polygons as POLY } from '../../../../geo/polygons.js'; class OpRough extends CamOp { constructor(state, op) { @@ -48,12 +48,13 @@ class OpRough extends CamOp { leave_z: op.leavez, ov_botz: op.ov_botz, ov_topz: op.ov_topz, + rotated: true, areas: { [widget.id]: areas.map(p => p.toArray()) }, surfaces: {} })); if (op.flats) { - let slicer = newSlicer(); + let slicer = newSlicer({ zflatup: true }); ops_list.push(new OpArea(state, { rename: op.rename ?? "flats", spindle: op.spindle, @@ -72,6 +73,7 @@ class OpRough extends CamOp { leave_z: op.leavez, ov_botz: op.ov_botz, ov_topz: op.ov_topz, + rotated: true, areas: { [widget.id]: areas.map(p => p.toArray()) }, surfaces: {}, flats: Object.keys(slicer.zFlat).map(v => parseFloat(v)).sort((a,b) => b-a), @@ -96,20 +98,32 @@ class OpRough extends CamOp { outline: !op.omitthru, ov_botz: op.ov_botz, ov_topz: op.ov_topz, + rotated: true, areas: { [widget.id]: areas.map(p => p.toArray()) }, surfaces: {}, thru: true })); } - for (let op of this.ops_list) { - await op.slice(progress); + let len = ops_list.length; + let per = 1 / len; + let bas = 0; + for (let op of ops_list) { + await op.slice(pct => progress(bas + pct * per)); + bas += per; } } async prepare(ops, progress) { - for (let op of this.ops_list) { - await op.prepare(ops, progress); + let { ops_list } = this; + let { setChangeOp } = ops; + let len = ops_list.length; + let per = 1 / len; + let bas = 0; + for (let op of ops_list) { + await op.prepare(ops, pct => progress(bas + pct * per)); + bas += per; + setChangeOp(); } } } diff --git a/src/kiri/mode/cam/op-shadow.js b/src/kiri/mode/cam/work/op-shadow.js similarity index 76% rename from src/kiri/mode/cam/op-shadow.js rename to src/kiri/mode/cam/work/op-shadow.js index 5891e7b9..c33cbb8f 100644 --- a/src/kiri/mode/cam/op-shadow.js +++ b/src/kiri/mode/cam/work/op-shadow.js @@ -1,8 +1,8 @@ /** Copyright Stewart Allen -- All Rights Reserved */ -import { CamOp } from './op.js'; -import { newSlice } from '../../core/slice.js'; -import { newPolygon } from '../../../geo/polygon.js'; +import { CamOp } from '../core/op.js'; +import { newSlice } from '../../../core/slice.js'; +import { newPolygon } from '../../../../geo/polygon.js'; /** * Computes the Part "shadow" and attaches relevant data to the "state" object @@ -21,12 +21,9 @@ class OpShadow extends CamOp { super(state, op); } - weight() { - return 0.1; - } - async slice(progress) { let state = this.state; + let { addSlices, settings, shadowAt, unsafe, widget, workarea } = state; let { devel } = settings.controller; @@ -36,21 +33,35 @@ class OpShadow extends CamOp { let skipTerrain = unsafe; let terrain = []; let tzindex = []; - let { bottom_z, top_z } = workarea; let minZ = Math.floor(bounds.min.z); let maxZ = Math.floor(bounds.max.z); for (let z = maxZ; z >= minZ; z--) { - // if (z <= top_z && z >= bottom_z) tzindex.push(z); } if (skipTerrain) { + let { stock } = settings; + let { center } = stock; + console.log("skipping terrain generation"); - shadowBase = [ newPolygon() ]; + shadowBase = [ newPolygon().centerRectangle(center, stock.x, stock.y) ]; tzindex = [ ]; + + state.shadow = { + base: shadowBase, // computed shadow union at base of part + holes: [], + skip: skipTerrain, + slices: [], // legacy / transitional + stack: [], // stack of shadow slices + }; + + return; } + // distributed pre-fill shadow layer cache + await widget.computeShadowStack(tzindex, prog => progress(prog / 2, 'shadow')); + // terrain is the "shadow stack" where index 0 = top of part // thus array.length -1 = bottom of part for (let i=0; i -- All Rights Reserved */ -import { CamOp } from './op.js'; +import { CamOp } from '../core/op.js'; import { OpArea } from './op-area.js'; class OpTrace extends CamOp { diff --git a/src/kiri/mode/cam/op-xray.js b/src/kiri/mode/cam/work/op-xray.js similarity index 87% rename from src/kiri/mode/cam/op-xray.js rename to src/kiri/mode/cam/work/op-xray.js index 12245c6c..b32ffe0f 100644 --- a/src/kiri/mode/cam/op-xray.js +++ b/src/kiri/mode/cam/work/op-xray.js @@ -1,8 +1,8 @@ /** Copyright Stewart Allen -- All Rights Reserved */ -import { CamOp } from './op.js'; -import { newSlice } from '../../core/slice.js'; -import { Slicer } from './slicer_cam.js'; +import { CamOp } from '../core/op.js'; +import { newSlice } from '../../../core/slice.js'; +import { Slicer } from './slicer-cam.js'; class OpXRay extends CamOp { constructor(state, op) { diff --git a/src/kiri/mode/cam/prepare.js b/src/kiri/mode/cam/work/prepare.js similarity index 87% rename from src/kiri/mode/cam/prepare.js rename to src/kiri/mode/cam/work/prepare.js index 2f51f51f..04610e6b 100644 --- a/src/kiri/mode/cam/prepare.js +++ b/src/kiri/mode/cam/work/prepare.js @@ -1,13 +1,12 @@ /** Copyright Stewart Allen -- All Rights Reserved */ -import { base, util } from '../../../geo/base.js'; -import { tip2tipEmit, poly2polyEmit } from '../../../geo/paths.js'; -import { newPoint } from '../../../geo/point.js'; -import { polygons as POLY } from '../../../geo/polygons.js'; -import { render } from '../../core/render.js'; -import { newPrint } from '../../core/print.js'; -import { Tool } from './tool.js'; -import { newPolygon } from '../../../geo/polygon.js'; +import { tip2tipEmit, poly2polyEmit } from '../../../../geo/paths.js'; +import { newPoint } from '../../../../geo/point.js'; +import { polygons as POLY } from '../../../../geo/polygons.js'; +import { render } from '../../../core/render.js'; +import { newPrint } from '../../../core/print.js'; +import { Tool } from '../core/tool.js'; +import { newPolygon } from '../../../../geo/polygon.js'; const debug = false; const debug_push = false; @@ -29,13 +28,20 @@ export async function cam_prepare(widgets, settings, update) { const count = active.length; const weight = 1 / count; const print = self.kiri_worker.current.print = newPrint(settings, active); - const { origin } = settings; + const { order, origin } = settings; + + // wait for safe eval setup + await print.ready(); // cam-specific storage print.output = []; // sort output by distance to origin - if (origin) { + if (order) { + active.sort((a,b) => { + return (order[a.id] ?? Infinity) - (order[b.id] ?? Infinity); + }); + } else if (origin) { let point = newPoint().move(origin); active.sort((w0,w1) => newPoint().move(w0.track.pos).distTo2D(point) - @@ -76,7 +82,8 @@ export async function cam_prepare(widgets, settings, update) { // process `prepare` paths for a single widget export async function prepare_one(widget, settings, print, firstPoint, update) { - let { device, process } = settings, + let { device, process, stock: set_stock } = settings, + { center } = set_stock, { alignTop } = settings.controller, { camArcEnabled, camArcResolution, camArcTolerance } = process, { camDepthFirst, camEaseAngle, camEaseDown } = process, @@ -107,6 +114,7 @@ export async function prepare_one(widget, settings, print, firstPoint, update) { originx = (camOriginCenter ? 0 : -stock.x / 2) + (camOriginOffX || 0), originy = (camOriginCenter ? 0 : -stock.y / 2) + (camOriginOffY || 0), origin = newPoint(originx, originy, zSafe), + coastline, contouring = false, currentOp, drillDown = 0, @@ -123,6 +131,7 @@ export async function prepare_one(widget, settings, print, firstPoint, update) { lastTravelBounds, newOutput = print.output || [], nextIsMove = true, + nextIsNewOp = false, plungeRate = camFastFeedZ, printPoint, tool, @@ -138,6 +147,21 @@ export async function prepare_one(widget, settings, print, firstPoint, update) { if (debug) console.log({ zSafe, wmx, wmy, wmz }); + // function d(o) { + // console.log('<-------------------'); + // for (let [k,v] of Object.entries(o)) console.log(k,v); + // console.log('------------------->'); + // } + + // d({ + // stock, + // sstck: set_stock, + // center, + // origin, + // wmx, + // wmy + // }); + function newLayer(op) { if (layerOut.length || layerOut.mode) { newOutput.push(layerOut); @@ -162,7 +186,8 @@ export async function prepare_one(widget, settings, print, firstPoint, update) { } } - function setContouring(bool, step) { + function setContouring(bool, step, coast) { + coastline = coast; contouring = bool; toolDiamMove = step ?? tool.getStepSize(currentOp.step) * 2; if (bool) setTravelBoundary(); @@ -330,6 +355,10 @@ export async function prepare_one(widget, settings, print, firstPoint, update) { nextIsMove = true; } + function setChangeOp() { + nextIsNewOp = true; + } + /** * Move a point by the widget's movement offset. * @param {Point} p - point to move @@ -355,6 +384,72 @@ export async function prepare_one(widget, settings, print, firstPoint, update) { .annotate({ slice: p.slice }); } + /** + * when moving between contour endpoints, check if we can + * instead route around the bounding area of the contour + * whih we call the coastline. + */ + function coastlineMove(point) { + let from = toWidgetCoords(printPoint); + let to = toWidgetCoords(point); + if (!coastline || from.distTo2D(to) < 0.01) { + return false; + } + let start = { dist: 1, poly: 0, pt: from }; + let end = { dist: 1, poly: 1, pt: to }; + for (let poly of coastline) { + let { points } = poly; + for (let i=0; i sp ? + sp + (pl - ep): + ep + (pl - sp); + let dir = 1; + let dist; + if (adist < bdist) { + dist = adist; + if (ep < sp) { + dir = -1; + } + } else { + dist = bdist; + if (ep < sp) { + ep += pl; + } else { + sp += pl; + dir = -1; + } + } + for (let i=sp, d=0; d < dist; i += dir, d++) { + layerPush(toWorkCoords(points[i % pl]), 1, 0, tool); + } + return true; + } + /** * emit a cut or move operation from the current location to a new location * @param {Point} point destination for move in widget coordinate space @@ -368,6 +463,7 @@ export async function prepare_one(widget, settings, print, firstPoint, update) { lastOp = currentOp; // translate widget point into workspace coordinates + let point_in = point; point = toWorkCoords(point); let { @@ -384,11 +480,12 @@ export async function prepare_one(widget, settings, print, firstPoint, update) { // 1. move to safe z of current point preserving angle // 2. move to safe z of new point preserving old angle // 3. move to safe z of new point with new angle - if (lop !== currentOp) { + if (nextIsNewOp || lop !== currentOp) { layerPush(printPoint.clone().setZ(zSafe).setA(printPoint.a), 0, feedRate, tool); layerPush(point.clone().setZ(zSafe).setA(printPoint.a), 0, feedRate, tool); layerPush(point.clone().setZ(zSafe), 0, feedRate, tool); newLayer(); + nextIsNewOp = false; } // consume forced next move flag and convert to move @@ -439,7 +536,9 @@ export async function prepare_one(widget, settings, print, firstPoint, update) { // contouring logic if (isMove && contouring) { - if (deltaXY > toolDiamMove) { + if (coastline && deltaXY < 5 && coastlineMove(point)) { + // console.log('coastline move'); + } else if (deltaXY > toolDiamMove) { upAndOver = true; } else if (absDeltaZ < 0.01) { if (debug) console.log('contour move as cut'); @@ -773,6 +872,7 @@ export async function prepare_one(widget, settings, print, firstPoint, update) { let { arc } = point; skip = arc.skip; type = arc.clockwise ? 2 : 3; + // arc center is relative to first point center = arc.center.clone().move({ x: -point.x, y: -point.y }); xfactors.push(xfactors.shift()); } @@ -844,7 +944,18 @@ export async function prepare_one(widget, settings, print, firstPoint, update) { // coming from a previous widget, use previous last point as starting point // make top start offset configurable - printPoint = firstPoint || origin; + if (firstPoint) { + // we're coming from another widget. offset compensated below + printPoint = firstPoint; + // console.log('coming from another widget', { printPoint }); + } else if (center) { + // we're the first widget output. offset is center + printPoint = origin.clone().move({ x: center.x, y: center.y }); + // console.log('first widget output', { printPoint }); + } else { + console.log({ missing_center_using_origin: origin }); + printPoint = origin.clone(); + } let ops = { addGCode, @@ -860,6 +971,7 @@ export async function prepare_one(widget, settings, print, firstPoint, update) { poly2polyEmit, polyEmit, printPoint, + setChangeOp, setContouring, setDrill, setLasering, @@ -878,6 +990,10 @@ export async function prepare_one(widget, settings, print, firstPoint, update) { // pre-flight check of ops for (let op of widget.camops) { + // Skip loop operations (they're expanded during slice) + if (op.op.type === 'loop') { + continue; + } opTot += op.weight(); // ensure tool related parameters are available // for the first index call when no tool is specified @@ -887,6 +1003,10 @@ export async function prepare_one(widget, settings, print, firstPoint, update) { } for (let op of widget.camops) { + // Skip loop operations (they're expanded during slice) + if (op.op.type === 'loop') { + continue; + } contouring = false; lasering = false; let cop = currentOp = op.op; @@ -904,7 +1024,7 @@ export async function prepare_one(widget, settings, print, firstPoint, update) { update((opSum + (progress * weight)) / opTot, message || op.type(), message); }); opSum += weight; - if (tool && printPoint) { + if (tool && printPoint && cop.type !== 'shadow') { newLayer(); if (!isIndex) { layerPush(printPoint.clone().setZ(stockZClear), 0, 0, tool); diff --git a/src/kiri/mode/cam/slice.js b/src/kiri/mode/cam/work/slice.js similarity index 87% rename from src/kiri/mode/cam/slice.js rename to src/kiri/mode/cam/work/slice.js index 2e633be1..c9735b9d 100644 --- a/src/kiri/mode/cam/slice.js +++ b/src/kiri/mode/cam/work/slice.js @@ -1,19 +1,33 @@ /** Copyright Stewart Allen -- All Rights Reserved */ -import { CAM } from './driver-be.js'; -import { newLine } from '../../../geo/line.js'; -import { newPoint } from '../../../geo/point.js'; -import { newPolygon } from '../../../geo/polygon.js'; -import { ops as OPS } from './ops.js'; -import { polygons as POLY } from '../../../geo/polygons.js'; -import { setSliceTracker } from '../../core/slice.js'; -import { Slicer as cam_slicer } from './slicer_cam.js'; -import { Tool } from './tool.js'; -import { util } from '../../../geo/base.js'; +import { CAM } from './init-work.js'; +import { newLine } from '../../../../geo/line.js'; +import { newPoint } from '../../../../geo/point.js'; +import { newPolygon } from '../../../../geo/polygon.js'; +import { ops as OPS } from '../core/ops.js'; +import { polygons as POLY } from '../../../../geo/polygons.js'; +import { setSliceTracker } from '../../../core/slice.js'; +import { Slicer as cam_slicer } from './slicer-cam.js'; +import { Tool } from '../core/tool.js'; +import { util } from '../../../../geo/base.js'; + +// state shared across all widget slicing +let shared; + +/** + * DRIVER SLICE CONTRACT + * + * global slice operation run before individual widget slice() operations + */ +export function cam_slice_pre(settings) { + shared = { ops: { } }; +} /** * DRIVER SLICE CONTRACT * + * slice operations run per-widget between slicePre and sliceAll + * * @param {Object} settings * @param {Widget} widget * @param {Function} output @@ -23,6 +37,7 @@ export async function cam_slice(settings, widget, onupdate, ondone) { // get tab widgets let tabW = widget.group.filter(w => w != widget); + // merge overlapping tabs for (let i=0; i { let { camStockX, camStockY, camStockZ, camStockOffset } = proc; - ({ camZTop, camZBottom, camZThru } = proc); + ({ camZTop, camZBottom } = proc); bounds = widget.getBoundingBox(); let pos = widget.track.pos; stock = camStockOffset ? { @@ -141,6 +157,10 @@ export async function cam_slice(settings, widget, onupdate, ondone) { onupdate(null,null,msg); } + function shadowAt(z) { + return widget.shadowAt(z); + } + if (unsafe) { console.log("disabling overhang safeties"); } @@ -179,11 +199,12 @@ export async function cam_slice(settings, widget, onupdate, ondone) { let slicer; let state = { addSlices, + axisIndex, + axisRotation, bounds, color, computeShadows, contourPolys, - cutPolys, cutTabs, dark, isIndexed, @@ -192,7 +213,7 @@ export async function cam_slice(settings, widget, onupdate, ondone) { setAxisIndex, setToolDiam, settings, - shadowAt(z) { return widget.shadowAt(z) }, + shadowAt, slicer, stock, tabs, @@ -232,6 +253,17 @@ export async function cam_slice(settings, widget, onupdate, ondone) { } } + function updateProgress(percent, message) { + const mark = Date.now(); + if (false) console.log({ + time: ((mark - lastTime)/1000).toFixed(2), + pct: ((percent ?? 0) * 100).toFixed(2), + msg: message + }); + onupdate(percent, message); + lastTime = mark; + } + function newSlicer(opts) { return new cam_slicer(widget, opts); } @@ -240,11 +272,10 @@ export async function cam_slice(settings, widget, onupdate, ondone) { return slicer = state.slicer = new cam_slicer(widget, opts); } - async function computeShadows() { - // console.log('(re)compute shadows'); - await new OPS.shadow(state, { type: "shadow", silent: true }).slice(progress => { - // console.log('reshadowing', progress.round(3)); - }); + async function computeShadows(progress) { + console.time('compute shadow'); + await new OPS.shadow(state, { type: "shadow", xsilent: true }).slice(progress); + console.timeEnd('compute shadow'); } function setToolDiam(toolDiam) { @@ -262,8 +293,8 @@ export async function cam_slice(settings, widget, onupdate, ondone) { } async function setAxisIndex(degrees = 0, absolute = true) { - axisIndex = absolute ? degrees : (axisIndex || 0) + degrees; - axisRotation = (Math.PI / 180) * axisIndex; + state.axisIndex = axisIndex = absolute ? degrees : (axisIndex || 0) + degrees; + state.axisRotation = axisRotation = (Math.PI / 180) * axisIndex; widget.setAxisIndex(isIndexed ? -axisIndex : 0); state.tabs = tabs = []; for (let tab of tabW) { @@ -305,25 +336,55 @@ export async function cam_slice(settings, widget, onupdate, ondone) { } if (false) { - opList.push(new OPS.xray(state, { type: "xray" })); + opList.push({ op: new OPS.xray(state, { type: "xray" }) }); } + // find index ops let activeOps = proc.ops.filter(op => !op.disabled); if (isIndexed) { // preface op list with OpIndex if (activeOps.length === 0 || activeOps[0].type !== 'index') { - opList.push(new OPS.index(state, { type: "index", degrees: 0, absolute: true })); - opTot += opList.peek().weight(); + opList.push({ op: new OPS.index(state, { type: "index", degrees: 0, absolute: true }) }); + opTot += opList.peek().op.weight(); } } else { // preface op list with OpShadow - opList.push(new OPS.shadow(state, { type: "shadow", silent: true })); - opTot += opList.peek().weight(); + opList.push({ op: new OPS.shadow(state, { type: "shadow", silent: true }) }); + opTot += opList.peek().op.weight(); } + // LOOP EXPANSION - duplicate next N operations M times + let expandedOps = []; + + for (let i=0; i op.type !== '|'); + if (remain < count) { + return error(`Loop operation requires ${count} following operation(s), but only ${remain.length - i - 1} available`); + } + for (let r=1; r { - onupdate((opSum + (progress * weight)) / opTot, message || op.type()); + updateProgress((opSum + (progress * weight)) / opTot, message || op.type()); }).catch(e => { operr = e; console.trace(e); @@ -637,7 +709,7 @@ export async function holes(settings, widget, individual, rec, onProgress) { stock = settings.stock || {}, isIndexed = proc.camStockIndexed, track = widget.track, - { camZTop, camZBottom, camZThru } = proc, + { camZBottom } = proc, // widget top z as defined by setTopz() wztop = track.top, // distance between top of part and top of stock @@ -784,12 +856,6 @@ function cutTabs(tabs, offset) { return out; } -function cutPolys(polys, offset) { - let noff = []; - offset.forEach(op => noff.appendAll( op.cut(POLY.union(polys, 0, true), true) )); - return POLY.reconnect(noff); -} - function contourPolys(widget, polys) { const raycaster = new THREE.Raycaster(); raycaster.ray.direction.set(0, 0, -1); // ray pointing down Z diff --git a/src/kiri/mode/cam/slicer_cam.js b/src/kiri/mode/cam/work/slicer-cam.js similarity index 95% rename from src/kiri/mode/cam/slicer_cam.js rename to src/kiri/mode/cam/work/slicer-cam.js index 4a4109ea..3f68d5c2 100644 --- a/src/kiri/mode/cam/slicer_cam.js +++ b/src/kiri/mode/cam/work/slicer-cam.js @@ -1,18 +1,18 @@ /** Copyright Stewart Allen -- All Rights Reserved */ -import { util } from '../../../geo/base.js'; -import { decode, decodePointArray } from '../../core/codec.js'; -import { newLine } from '../../../geo/line.js'; -import { newPoint } from '../../../geo/point.js'; -import { newSlice } from '../../core/slice.js'; -import { polygons as POLY } from '../../../geo/polygons.js'; +import { util } from '../../../../geo/base.js'; +import { decode, decodePointArray } from '../../../core/codec.js'; +import { newLine } from '../../../../geo/line.js'; +import { newPoint } from '../../../../geo/point.js'; +import { newSlice } from '../../../core/slice.js'; +import { polygons as POLY } from '../../../../geo/polygons.js'; import { checkOverUnderOn, intersectPoints, makeZLine, removeDuplicateLines, sliceConnect, -} from '../../../geo/slicer.js'; +} from '../../../../geo/slicer.js'; const timing = false; const zDecimal = 3; @@ -101,6 +101,11 @@ export class Slicer { // use co-flat and co-line detection to adjust slice Z if (Math.abs(p1.z - p2.z) < epsilon && Math.abs(p2.z - p3.z) < epsilon) { // detect zFlat faces to avoid slicing directly on them + if (opt.zflatup) { + // filter to up-facing flats (more expensive) + let norm = THREE.computeFaceNormal(p1,p2,p3); + if (norm.z < -0.00001) continue; + } let zkey = p1.z.toFixed(zDecimal), area = Math.abs(util.area2(p1, p2, p3)) / 2; if (!zFlat[zkey]) { diff --git a/src/kiri/mode/cam/slicer_topo.js b/src/kiri/mode/cam/work/slicer-topo.js similarity index 98% rename from src/kiri/mode/cam/slicer_topo.js rename to src/kiri/mode/cam/work/slicer-topo.js index 7aeeec3b..3c80e4b3 100644 --- a/src/kiri/mode/cam/slicer_topo.js +++ b/src/kiri/mode/cam/work/slicer-topo.js @@ -1,12 +1,12 @@ /** Copyright Stewart Allen -- All Rights Reserved */ -import { newPoint } from '../../../geo/point.js'; +import { newPoint } from '../../../../geo/point.js'; import { checkOverUnderOn, makeZLine, removeDuplicateLines, intersectPoints -} from '../../../geo/slicer.js'; +} from '../../../../geo/slicer.js'; /** Slicer used in Topo3 and Topo4 to find contour lines */ export class Slicer { diff --git a/src/kiri/mode/cam/topo3.js b/src/kiri/mode/cam/work/topo3.js similarity index 93% rename from src/kiri/mode/cam/topo3.js rename to src/kiri/mode/cam/work/topo3.js index 1253cdf1..495c827c 100644 --- a/src/kiri/mode/cam/topo3.js +++ b/src/kiri/mode/cam/work/topo3.js @@ -1,12 +1,12 @@ /** Copyright Stewart Allen -- All Rights Reserved */ -import { codec } from '../../core/codec.js'; -import { newPoint } from '../../../geo/point.js'; -import { newPolygon } from '../../../geo/polygon.js'; -import { newSlice } from '../../core/slice.js'; -import { Slicer as topo_slicer } from './slicer_topo.js'; -import { polygons as POLY } from '../../../geo/polygons.js'; -import { Tool } from './tool.js'; +import { codec } from '../../../core/codec.js'; +import { newPoint } from '../../../../geo/point.js'; +import { newPolygon } from '../../../../geo/polygon.js'; +import { newSlice } from '../../../core/slice.js'; +import { Slicer as topo_slicer } from './slicer-topo.js'; +import { polygons as POLY } from '../../../../geo/polygons.js'; +import { Tool } from '../core/tool.js'; const RAD2DEG = 180 / Math.PI; const DEG2RAD = Math.PI / 180; @@ -77,7 +77,7 @@ export class Topo { gridDelta = Math.floor(partOff / resolution), debug_clips = true; - if (process.camStockClipTo) { + if (contour.clipto) { let { stock } = settings; let { center, x, y } = stock; clipTo.push(newPolygon().centerRectangle(center, x, y)); @@ -166,6 +166,16 @@ export class Topo { zFloor: zBottom - 1, onProgress(pct) { console.log({ pct }); onupdate(pct/100, 100) } }); + gpu.mode = 'tracing'; + // create coastline path around part for tip-to-tip travels + // convert shadow/clip poly lines to raster float32 array groups + let paths = POLY.flatten(clipTo).map(poly => poly.points.map(p => [ p.x, p.y ]).flat().toFloat32()); + let coastline = await gpu.generateToolpaths({ + paths, + step: 1, + zFloor: zBottom + }); + this.coastline = coastline.paths.map(arr => newPolygon().fromArray([0,...arr])); gpu.terminate(); let { numScanlines, pointsPerLine, pathData } = output; @@ -241,7 +251,7 @@ export class Topo { } } // raise output points when inside tab boundaries - if (!inside && tabsOn && clipTab.length) + if (tabsOn && clipTab.length) for (let poly of lines) { for (let p of poly.points) { for (let clip of clipTab) { @@ -255,24 +265,40 @@ export class Topo { // drop interior points along a continuous slope for (let poly of lines) { let { points } = poly; + if (points.length < 2) continue; let merged = [ points[0] ]; - let lp = points[1]; - let dz = points[0].z - lp.z; - for (let i=2; i flatness) { - // slope changed - merged.push(lp); - dz = ndz; - } else if (curvesOnly && Math.abs(ndz) < flatness) { + let lastP = points[0]; + let latent = null; + let lastSlope = undefined; + for (let i=1; i -- All Rights Reserved */ -import { codec } from '../../core/codec.js'; -import { newPoint } from '../../../geo/point.js'; -import { newPolygon } from '../../../geo/polygon.js'; -import { sliceConnect } from '../../../geo/slicer.js'; -import { newSlice } from '../../core/slice.js'; -import { Tool } from './tool.js'; -import { Slicer as topo_slicer } from './slicer_topo.js'; +import { codec } from '../../../core/codec.js'; +import { newPoint } from '../../../../geo/point.js'; +import { newPolygon } from '../../../../geo/polygon.js'; +import { sliceConnect } from '../../../../geo/slicer.js'; +import { newSlice } from '../../../core/slice.js'; +import { Tool } from '../core/tool.js'; +import { Slicer as topo_slicer } from './slicer-topo.js'; const RAD2DEG = 180 / Math.PI; const DEG2RAD = Math.PI / 180; @@ -42,7 +42,9 @@ export class Topo { tolerance = op.tolerance, resolution = (tolerance ? tolerance : 1 / Math.sqrt(density / (span.x * span.y))).round(5), step = this.step = (tool.traceOffset() * 2) * op.step, - angle = this.angle = op.angle || 1; + angle = this.angle = op.angle || 1, + down = this.down = op.down, + expand = this.expand = op.expand; if (tool.isTaperMill() && step === 0) { step = this.step = op.step * tool.unitScale(); @@ -160,6 +162,7 @@ export class Topo { async sliceGPU(onupdate) { const { angle, diam, leave, linear, offStart, offEnd, resolution, tool, units, vertices, zBottom } = this; + const { down, expand } = this; // invert tool Z offset for gpu code let toolBounds = new THREE.Box3() @@ -200,13 +203,13 @@ export class Topo { await gpu.loadTerrain({ triangles: vertices, boundsOverride, - onProgress(pct) { onupdate(pct/100) } + onProgress: (pct) => onupdate(pct / 1000) }); let output = await gpu.generateToolpaths({ xStep, yStep: 1, zFloor: zBottom, - onProgress(i,j) { onupdate(i/j) } + onProgress: (i,j) => onupdate(0.1 + i / j) }); gpu.terminate(); @@ -225,7 +228,7 @@ export class Topo { let slice = newSlice(i); slice.index = i; slice.camLines = [ newPolygon(rows[i]).setOpen() ]; - if (i % 2 === 1) slice.camLines[0].reverse(); + if (!down && i % 2 === 1) slice.camLines[0].reverse(); slices.push(slice); } } else { @@ -234,12 +237,53 @@ export class Topo { let slice = newSlice(i); let points = rows.map(row => row[i]); points.push(points[0].clone().setA(-360)); - if (i % 2 === 1) points.reverse(); + if (!down && i % 2 === 1) points.reverse(); slice.index = i; slice.camLines = [ newPolygon(points).setOpen() ]; slices.push(slice); } } + let maxZ = 0; + if (down) { + for (let r of rows) + for (let p of r) { + maxZ = Math.max(maxZ, p.z); + } + maxZ += expand; + } + if (down) { + // step "up" points on trace until they max out + // then reverse the stack to cut top down + let reverse = 0; + for (let slice of slices) { + let z = slice.z; + let poly = slice.camLines[0].clone(); + let pMinZ = poly.minZ(); + let steps = Math.floor((maxZ - pMinZ) / down); + let stack = [ poly.clone() ]; + for (let i=0; i { + if (reverse++ % 2 === 1) poly.reverse(); + let slice = newSlice(z); + slice.camLines = [ poly ]; + return slice; + }); + // first move of new stack is to maxZ (problematic) + // let first = stack[0].points; + // first.splice(0,0,first[0].clone().setZ(maxZ)); + // last move of stack is to maxZ + let last = stack.peek(); + last.push(last.last().clone().setZ(maxZ)); + } + slices = this.gpu_slices = slices.map(slice => slice.stack).flat(); + slices.forEach((slice,index) => slice.index = index); + } for (let slice of slices) { slice.output() .setLayer("lathe", { line: this.lineColor }) diff --git a/src/kiri/mode/drag/driver.js b/src/kiri/mode/drag/driver.js deleted file mode 100644 index a6b5c853..00000000 --- a/src/kiri/mode/drag/driver.js +++ /dev/null @@ -1,13 +0,0 @@ -/** Copyright Stewart Allen -- All Rights Reserved */ - -import { LASER, TYPE } from '../laser/driver.js'; - -function init(worker) { - // console.log({ DRAGKNIFE_INIT: worker }); -} - -export const DRAG = Object.assign({}, LASER, { - type: TYPE.DRAG, - name: 'DragKnife', - init -}); diff --git a/src/kiri/mode/drag/client.js b/src/kiri/mode/drag/init-ui.js similarity index 100% rename from src/kiri/mode/drag/client.js rename to src/kiri/mode/drag/init-ui.js diff --git a/src/kiri/mode/drag/init-work.js b/src/kiri/mode/drag/init-work.js new file mode 100644 index 00000000..801318d9 --- /dev/null +++ b/src/kiri/mode/drag/init-work.js @@ -0,0 +1,214 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { LASER, TYPE, FUNC } from '../laser/init-work.js'; +import { newPoint } from '../../../geo/point.js'; +import { newPolygon } from '../../../geo/polygon.js'; +import { newSlopeFromAngle } from '../../../geo/slope.js'; + +function init(worker) { + FUNC.generateDragKnifePath = generateDragKnifePath; +} + +/** + * Generate drag knife toolpath for a polygon. + * The knife blade trails behind the rotation axis by knifeOffset distance. + * This function creates compensated paths with pivot arcs at corners. + * + * @param {Polygon} polygon - Input polygon to cut + * @param {number} knifeOffset - Distance from Z-axis center to blade tip + * @param {Object} options - Configuration options + * @param {number} [options.angleThreshold=10] - Angle (degrees) below which vertices are treated as curves + * @param {number} [options.lookahead=3] - Number of vertices for direction smoothing on curves + * @param {boolean} [options.overcut=false] - Add slight overcut at sharp corners + * @param {number} [options.overcutDistance=0.1] - Distance to overcut in mm + * @returns {Polygon} New polygon with drag knife compensation applied + */ +function generateDragKnifePath(polygon, knifeOffset, options = {}) { + const { + angleThreshold = 5, // degrees - minimum angle change to insert arc + arcSegmentAngle = 5 // degrees - angle between arc segments + } = options; + + // Validate inputs + if (!knifeOffset || Math.abs(knifeOffset) < 0.001) { + return polygon.clone(); + } + + polygon.setClockwise(); + const points = polygon.points; + const len = points.length; + + if (len < 2) { + return polygon.clone(); + } + + const DEG2RAD = Math.PI / 180; + const thresholdRad = angleThreshold * DEG2RAD; + const arcSegRad = arcSegmentAngle * DEG2RAD; + + // For closed polygons vs open paths + const isClosed = !polygon.open; + const numSegments = isClosed ? len : len - 1; + + // Calculate segment directions and angles + const segments = []; + for (let i = 0; i < numSegments; i++) { + const p1 = points[i]; + const p2 = points[(i + 1) % len]; + const dx = p2.x - p1.x; + const dy = p2.y - p1.y; + const length = Math.sqrt(dx * dx + dy * dy); + + if (length > 0.001) { + segments.push({ + index: i, + point: p1, + angle: Math.atan2(dy, dx), // Direction of travel + length: length + }); + } + } + + if (segments.length === 0) { + return polygon.clone(); + } + + const output = []; + + // Process each segment + for (let i = 0; i < segments.length; i++) { + const seg = segments[i]; + const isLastSegment = i === segments.length - 1; + const nextSeg = isClosed ? segments[(i + 1) % segments.length] : + isLastSegment ? null : segments[i + 1]; + + // Key insight from dragknife-repath: + // Offset the point in the DIRECTION OF TRAVEL by knife offset + // The knife blade trails behind the rotation axis + const offsetX = seg.point.x + Math.cos(seg.angle) * knifeOffset; + const offsetY = seg.point.y + Math.sin(seg.angle) * knifeOffset; + output.push(newPoint(offsetX, offsetY, seg.point.z)); + + // Check if we need a swivel arc at the end of this segment + if (nextSeg) { + // Calculate angle change between this segment and the next + let angleDiff = nextSeg.angle - seg.angle; + + // Normalize angle difference to [-PI, PI] + while (angleDiff > Math.PI) angleDiff -= 2 * Math.PI; + while (angleDiff < -Math.PI) angleDiff += 2 * Math.PI; + + // If angle change exceeds threshold, insert arc to rotate knife + if (Math.abs(angleDiff) > thresholdRad) { + // The arc is centered at the vertex between segments + const vertex = points[(seg.index + 1) % len]; + + // Number of arc segments based on angle change + const arcSteps = Math.max(2, Math.ceil(Math.abs(angleDiff) / arcSegRad)); + + // Generate arc from current angle to next angle + for (let step = 1; step <= arcSteps; step++) { + const t = step / arcSteps; + const angle = seg.angle + angleDiff * t; + const arcX = vertex.x + Math.cos(angle) * knifeOffset; + const arcY = vertex.y + Math.sin(angle) * knifeOffset; + output.push(newPoint(arcX, arcY, vertex.z)); + } + } + } + } + + // For open paths, add the final point offset in the last segment's direction + if (!isClosed && segments.length > 0) { + const lastSeg = segments[segments.length - 1]; + const lastPoint = points[len - 1]; + const offsetX = lastPoint.x + Math.cos(lastSeg.angle) * knifeOffset; + const offsetY = lastPoint.y + Math.sin(lastSeg.angle) * knifeOffset; + output.push(newPoint(offsetX, offsetY, lastPoint.z)); + } + + // Create new polygon with compensated path + const result = newPolygon(output); + result.open = polygon.open; + + return result; +} + +// start to the "left" of the first point +function addKnifeRadii(poly, tipoff) { + poly.setClockwise(); + let oldpts = poly.points.slice(); + + // find leftpoint and make that the first point + let start = oldpts[0]; + let startI = 0; + let inner = tipoff < 0; + tipoff = Math.abs(tipoff); + for (let i=1; i start.x || (pt.x == start.x && pt.y < start.y))) { + start = pt; + startI = i; + } else if (!inner && (pt.x < start.x || (pt.x == start.x && pt.y > start.y))) { + start = pt; + startI = i; + } + } + if (startI > 0) { + oldpts = oldpts.slice(startI,oldpts.length).appendAll(oldpts.slice(0,startI)); + } + + let lastpt = oldpts[0].clone().move({x:-tipoff,y:0,z:0}); + let lastsl = lastpt.slopeTo(oldpts[0]).toUnit(); + let newpts = [ lastpt, lastpt = oldpts[0].clone() ]; + let tmp; + for (let i=1; i= 10) { + if (tipoff && lastpt.distTo2D(nextpt) >= tipoff) { + arc(lastpt, tipoff, lastsl, nextsl, newpts); + } else { + // todo handle short segments + // newpts.push(lastpt.projectOnSlope(lastsl, tipoff) ); + // newpts.push( lastpt.projectOnSlope(nextsl, tipoff) ); + } + } + newpts.push(nextpt); + lastsl = nextsl; + lastpt = nextpt; + } + newpts.push( tmp = lastpt.projectOnSlope(lastsl, tipoff) ); + // newpts.push( tmp.clone().move({x:tipoff, y:0, z: 0}) ); + poly.open = true; + poly.points = newpts; + + return poly; +} + +// convert arc into line segments +function arc(center, rad, s1, s2, out) { + let a1 = s1.angle; + let step = 5; + let diff = s1.angleDiff(s2, true); + let ticks = Math.abs(Math.floor(diff / step)); + let dir = Math.sign(diff); + let off = (diff % step) / 2; + if (off == 0) { + ticks++; + } else { + out.push( center.projectOnSlope(s1, rad) ); + } + while (ticks-- > 0) { + out.push( center.projectOnSlope(newSlopeFromAngle(a1 + off), rad) ); + a1 += step * dir; + } + out.push( center.projectOnSlope(s2, rad) ); +} + +export const DRAG = Object.assign({}, LASER, { + type: TYPE.DRAG, + name: 'DragKnife', + init, + generateDragKnifePath +}); diff --git a/src/kiri/mode/fdm/init-menu.js b/src/kiri/mode/fdm/app/init-menu.js similarity index 90% rename from src/kiri/mode/fdm/init-menu.js rename to src/kiri/mode/fdm/app/init-menu.js index e04dc093..d0dbd6cb 100644 --- a/src/kiri/mode/fdm/init-menu.js +++ b/src/kiri/mode/fdm/app/init-menu.js @@ -1,6 +1,6 @@ /** Copyright Stewart Allen -- All Rights Reserved */ -import { api } from '../../core/api.js'; +import { api } from '../../../app/api.js'; let LANG = api.language.current; let { FDM } = api.const.MODES, @@ -41,6 +41,10 @@ function optSelected(sel) { return opt ? opt.value : undefined; } +function manualSupport() { + return api.conf.proc().sliceSupportType === 'manual'; +} + function hasInfill() { return optSelected(ui.sliceFillType) !== 'none' } @@ -61,6 +65,14 @@ function isNotBelt() { return !isBelt(); } +function isTree() { + return api.conf.proc().sliceSupportTree; +} + +function notFwRetract() { + return !api.conf.get().device.fwRetract; +} + export function menu() { return { @@ -89,6 +101,7 @@ export function menu() { sliceFillAngle: newInput(LANG.fi_angl_s, {title:LANG.fi_angl_l, convert:toFloat}), sliceFillGrow: newInput(LANG.fi_grow_s, {title:LANG.fi_grow_l, convert:toFloat}), sliceSolidMinArea: newInput(LANG.ad_msol_s, {title:LANG.ad_msol_l, convert:toFloat}), + sliceSolidMinThick: newInput(LANG.ad_mthk_s, {title:LANG.ad_mthk_l, convert:toFloat}), _____: newGroup(LANG.fi_menu, $('fdm-fill'), { modes:FDM, driven, hideable, separator, group:"fdm-fill" }), sliceFillType: newSelect(LANG.fi_type, {trigger}, "infill"), sliceFillSparse: newInput(LANG.fi_pcnt_s, {title:LANG.fi_pcnt_l, convert:toFloat, bound:bound(0.0,1.0), show:hasInfill}), @@ -103,29 +116,23 @@ export function menu() { outputFanSpeed: newInput(LANG.ou_fans_s, {title:LANG.ou_fans_l, convert:toInt, bound:bound(0,255)}), outputMinLayerTime: newInput(LANG.ou_layt_s, { title:LANG.ou_layt_l, convert:toInt, bound:bound(0,200) }), _____: newGroup(LANG.sp_menu, $('fdm-support'), { modes:FDM, driven, hideable, separator, group:"fdm-supp" }), + sliceSupportType: newSelect(LANG.sp_type_s, {title:LANG.sp_type_l, trigger}, "support"), sliceSupportNozzle: newSelect(LANG.sp_nozl_s, {title:LANG.sp_nozl_l, show:isMultiHead}, "extruders"), - sliceSupportDensity: newInput(LANG.sp_dens_s, {title:LANG.sp_dens_l, convert:toFloat, bound:bound(0.0,1.0)}), - sliceSupportSize: newInput(LANG.sp_size_s, {title:LANG.sp_size_l, convert:toFloat, bound:bound(1.0,200.0)}), - sliceSupportOffset: newInput(LANG.sp_offs_s, {title:LANG.sp_offs_l, convert:toFloat, bound:bound(0.0,200.0)}), - sliceSupportGap: newInput(LANG.sp_gaps_s, {title:LANG.sp_gaps_l, convert:toInt, bound:bound(0,5)}), - sliceSupportArea: newInput(LANG.sp_area_s, {title:LANG.sp_area_l, convert:toFloat, bound:bound(0.0,200.0)}), - sliceSupportExtra: newInput(LANG.sp_xpnd_s, {title:LANG.sp_xpnd_l, convert:toFloat, bound:bound(0.0,10.0)}), - sliceSupportGrow: newInput(LANG.sp_grow_s, {title:LANG.sp_grow_l, convert:toFloat, bound:bound(0.0,10.0)}), + separator: newBlank({ class:"set-sep", driven }), sliceSupportAngle: newInput(LANG.sp_angl_s, {title:LANG.sp_angl_l, convert:toFloat, bound:bound(0.0,90.0)}), + sliceSupportDensity: newInput(LANG.sp_dens_s, {title:LANG.sp_dens_l, convert:toFloat, bound:bound(0.0,1.0)}), + sliceSupportGap: newInput(LANG.sp_gaps_s, {title:LANG.sp_gaps_l, convert:toInt, bound:bound(0,5)}), + sliceSupportOffset: newInput(LANG.sp_offs_s, {title:LANG.sp_offs_l, convert:toFloat, bound:bound(0.0,200.0)}), + sliceSupportExtra: newInput(LANG.sp_xpnd_s, {title:LANG.sp_xpnd_l, convert:toFloat, bound:bound(0.0,10.0)}), sliceSupportSpan: newInput(LANG.sp_span_s, {title:LANG.sp_span_l, convert:toFloat, bound:bound(0.0,200.0), show:() => ui.sliceSupportEnable.checked }), separator: newBlank({ class:"set-sep", driven }), - sliceSupportEnable: newBoolean(LANG.sp_auto_s, onBooleanClick, {title:LANG.sp_auto_l, show:isNotBelt}), - sliceSupportOutline: newBoolean(LANG.sp_outl_s, onBooleanClick, {title:LANG.sp_outl_l}), - separator: newBlank({ class:"set-sep", driven }), - sliceSupportGen: newRow([ - ui.ssaGen = newButton(LANG.sp_detect, onButtonClick, {class: "f-col grow a-center"}) - ], { modes: FDM, class: "ext-buttons f-row grow" }), - separator: newBlank({ class:"set-sep", driven }), + sliceSupportOutline: newBoolean(LANG.sp_outl_s, onBooleanClick, {title:LANG.sp_outl_l, xshow: () => !isTree() }), + separator: newBlank({ class:"set-sep", driven, show:manualSupport }), sliceSupportManual: newRow([ (ui.ssmAdd = newButton(undefined, onButtonClick, {icon:''})), - (ui.ssmDun = newButton(undefined, onButtonClick, {icon:''})), + (ui.ssmDel = newButton(undefined, onButtonClick, {icon:''})), (ui.ssmClr = newButton(undefined, onButtonClick, {icon:''})) - ], {class:"ext-buttons f-row"}), + ], {class:"ext-buttons f-row", show:manualSupport}), _____: newGroup(LANG.fl_menu, $('fdm-base'), { modes:FDM, driven, hideable, separator, group:"fdm-base" }), firstSliceHeight: newInput(LANG.fl_lahi_s, {title:LANG.fl_lahi_l, convert:toFloat, show:isNotBelt}), firstLayerNozzleTemp:newInput(LANG.fl_nozl_s, {title:LANG.fl_nozl_l, convert:toInt, show:isNotBelt}), @@ -160,10 +167,14 @@ export function menu() { outputFillMult: newInput(LANG.ou_flml_s, {title:LANG.ou_exml_l, convert:toFloat, bound:bound(0.0,2.0)}), outputSparseMult: newInput(LANG.ou_spml_s, {title:LANG.ou_exml_l, convert:toFloat, bound:bound(0.0,2.0)}), separator: newBlank({ class:"set-sep", driven }), - outputRetractDist: newInput(LANG.ad_rdst_s, {title:LANG.ad_rdst_l, convert:toFloat}), - outputRetractSpeed: newInput(LANG.ad_rrat_s, {title:LANG.ad_rrat_l, convert:toInt}), + outputRetractDist: newInput(LANG.ad_rdst_s, {title:LANG.ad_rdst_l, convert:toFloat, show:notFwRetract}), + outputRetractSpeed: newInput(LANG.ad_rrat_s, {title:LANG.ad_rrat_l, convert:toInt, show:notFwRetract}), outputRetractWipe: newInput(LANG.ad_wpln_s, {title:LANG.ad_wpln_l, convert:toFloat, bound:bound(0.0,10)}), separator: newBlank({ class:"set-sep", driven }), + fdmArcEnabled: newBoolean(LANG.cx_arce_s, onBooleanClick, { title:LANG.cx_arce_l }), + fdmArcTolerance: newInput(LANG.cx_arct_s, {title:LANG.cx_arct_l, convert:toFloat, bound:bound(0,100), trigger, show:() => ui.fdmArcEnabled.checked}), + fdmArcResolution: newInput(LANG.cx_arcr_s, {title:LANG.cx_arcr_l, convert:toFloat, bound:bound(0,180), trigger, show:() => ui.fdmArcEnabled.checked}), + separator: newBlank({ class:"set-sep", driven, show:() => ui.fdmArcEnabled.checked}), outputAvoidGaps: newBoolean(LANG.ad_agap_s, onBooleanClick, {title:LANG.ad_agap_l}), sliceSolidify: newBoolean(LANG.ad_sold_s, onBooleanClick, {title:LANG.ad_sold_l}), separator: newBlank({ class:"set-sep", driven, show:isBelt }), @@ -171,7 +182,6 @@ export function menu() { _____: newGroup(LANG.ad_menu, $('fdm-expert'), { modes:FDM, driven, hideable, separator, group:"fdm-xprt" }), sliceAngle: newInput(LANG.sl_angl_s, {title:LANG.sl_angl_l, convert:toFloat, show:isBelt}), antiBacklash: newInput(LANG.ad_abkl_s, {title:LANG.ad_abkl_l, convert:toFloat, bound:bound(0,3)}), - arcTolerance: newInput(LANG.ad_arct_s, {title:LANG.ad_arct_l, convert:toFloat, bound:bound(0,1.0), show:() => { return isNotBelt() }}), outputRetractDwell: newInput(LANG.ad_rdwl_s, {title:LANG.ad_rdwl_l, convert:toInt}), outputMinSpeed: newInput(LANG.ad_mins_s, {title:LANG.ad_mins_l, convert:toFloat, bound:bound(1,200)}), outputMaxFlowrate: newInput(LANG.ad_maxf_s, {title:LANG.ad_maxf_l, convert:toFloat, bound:bound(1,200)}), diff --git a/src/kiri/mode/fdm/app/init-ui.js b/src/kiri/mode/fdm/app/init-ui.js new file mode 100644 index 00000000..00837bd5 --- /dev/null +++ b/src/kiri/mode/fdm/app/init-ui.js @@ -0,0 +1,390 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { newPoint } from '../../../../geo/point.js'; +import { $ } from '../../../../moto/webui.js'; +import { api } from '../../../app/api.js'; +import { colorSchemeRegistry } from '../../../app/color/schemes.js'; +import { conf } from '../../../app/conf/defaults.js'; +import { addPaintOverlayTexture, updatePaintOverlay } from '../../../app/paint.js'; + +const { VIEWS } = api.const; + +const LANG = api.language.current; + +let lastView, + addingSupports = false, + removingSupports = false, + isFdmMode = false, + alert = [], + down; + +export function init() { + const { ui } = api; + const proc_keys = Object.keys(conf.defaults.fdm.p); + const rangeVars = { + // slice + // "sliceHeight": LANG.sl_lahi_s, + "sliceShells": LANG.sl_shel_s, + "sliceFillType": LANG.fi_type, + "sliceFillWidth": LANG.fi_wdth_s, + "sliceFillSparse": LANG.fi_pcnt_s, + "sliceFillGrow": LANG.fi_grow_s, + "sliceSolidMinArea": LANG.ad_msol_s, + "sliceSolidMinThick": LANG.ad_mthk_s, + "sliceTopLayers": LANG.sl_ltop_s, + "sliceBottomLayers": LANG.sl_lbot_s, + "firstLayerRate": LANG.fl_rate_s, + "firstLayerFanSpeed": LANG.ou_fans_s, + // prepare + "sliceShellOrder": LANG.sl_ordr_s, + "sliceFillOverlap": LANG.fi_over_s, + "outputFeedrate": LANG.ou_feed_s, + "outputFinishrate": LANG.ou_fini_s, + "outputShellMult": LANG.ou_shml_s, + "outputFillMult": LANG.ou_flml_s, + "outputSparseMult": LANG.ou_spml_s, + "outputRetractWipe": LANG.ad_wpln_s, + "outputAlternating": LANG.ad_altr_s, + "outputShortPoly": LANG.ad_spol_s, + "outputMinSpeed": LANG.ad_mins_s, + "outputCoastDist": LANG.ad_scst_s, + "outputLoops": LANG.ag_loop_s, + "sliceSupportDensity": LANG.sp_dens_s, + "sliceSupportOffset": LANG.sp_offs_s, + "sliceLayerStart": LANG.sl_strt_s, + "firstLayerBrim": LANG.fl_brim_s, + "firstLayerBrimIn": LANG.fl_brin_s, + "firstLayerBrimTrig": LANG.fl_brmn_s, + "firstLayerBrimGap": LANG.fl_brgp_s, + // export + "zHopDistance": LANG.ad_zhop_s, + "arcTolerance": LANG.ad_zhop_s, + "antiBacklash": LANG.ad_abkl_s, + "outputTemp": LANG.ou_nozl_s, + "outputBedTemp": LANG.ou_bedd_s, + "outputFanSpeed": LANG.ou_fans_s, + "outputRetractDist": LANG.ad_rdst_s, + "outputRetractSpeed": LANG.ad_rrat_s, + "outputRetractDwell": LANG.ad_rdwl_s, + "outputLayerRetract": LANG.ad_lret_s + }; + + for (let key of Object.keys(rangeVars)) { + if (ui[key]) { + ui[key].range = true; + } + } + + function clearRanges() { + api.conf.get().process.ranges = []; + ui.fdmRanges.innerHTML = ''; + } + + function onEventFDM(topic, fn) { + return api.event.on(topic, function() { + if (!isFdmMode) return; + fn(...arguments); + }); + } + + // re-render ranges menu + function updateRanges(ranges = []) { + let html = []; + let bind = []; + let now = Date.now(); + let sorted = ranges.sort((a,b) => b.lo - a.lo); + for (let range of sorted) { + let id = (now++).toString(36); + let rows = Object.entries(range.fields).map(a => `
`).join(''); + let hover = `
${rows}
`; + let info = ``; + html.appendAll([ + `
${hover}${info}
` + ]); + bind.push({id, range}); + } + ui.fdmRanges.innerHTML = html.join(''); + for (let rec of bind) { + $(`sel_${rec.id}`).onclick = () => { + api.show.layer(rec.range.hi, rec.range.lo); + }; + $(`del_${rec.id}`).onclick = () => { + let io = ranges.indexOf(rec.range); + ranges.splice(io,1); + updateRanges(ranges); + }; + } + } + + api.event.on("function.animate", (mode) => { + if (!(isFdmMode && lastView === VIEWS.PREVIEW)) { + return; + } + let slider = $('top-slider'); + slider.style.display = 'flex'; + slider.oninput = slider.onchange = (ev) => { + api.const.STACKS.setFraction(parseInt(ev.target.value)/1000); + api.space.update(); + }; + $('anim-slider').value = 500; + api.const.STACKS.setFraction(0.5); + }); + + api.event.on("mode.set", mode => { + isFdmMode = mode === 'FDM'; + // hide/show fdm mode elements + for (let el of [...document.getElementsByClassName('mode-fdm')]) { + api.uc.setClass(el, 'hide', !isFdmMode); + } + }); + + api.event.on("view.set", view => { + $('top-slider').style.display = 'none'; + lastView = view; + supportDone(); + // let ranges = api.conf.get().process.ranges; + if (isFdmMode) { + if (lastView === VIEWS.SLICE) { + for (let key of proc_keys) { + if (ui[key] && !ui[key].range) { + ui[key].disabled = true; + } + } + } else { + for (let key of proc_keys) { + if (ui[key]) ui[key].disabled = false; + } + } + } + }); + + api.event.on("range.updates", updateRanges); + + api.event.on("settings.load", (settings) => { + if (settings.mode !== 'FDM') return; + updateRanges(settings.process.ranges); + }); + + api.event.on("settings.saved", (settings) => { + updateRanges(settings.process.ranges); + }); + + api.event.on("button.click", target => { + switch (target) { + case api.ui.ssmAdd: return supportStart({ remove: false }); + case api.ui.ssmDel: return supportStart({ remove: true }); + case api.ui.ssmClr: return supportClear(); + } + }); + + onEventFDM("key.esc", supportDone); + + onEventFDM("slice.begin", supportDone); + + // onEventFDM("slice.end", api.noop); + + onEventFDM("selection.scale", () => { + clearRanges(); + supportClear() + }); + + // onEventFDM("widget.delete", api.noop); + + // onEventFDM("widget.duplicate", api.noop); + + // onEventFDM("widget.mirror", api.noop); + + onEventFDM("widget.rotate", rot => { + let {widget, x, y, z} = rot; + if (x || y) { + clearWidgetSupports(widget); + } else { + // rotate supports when rotation is constrained to Z axis + rotateWidgetPaint(widget, z); + } + api.conf.save(); + }); + + onEventFDM("mouse.hover.up", (on = {}) => { + if (!addingSupports) { + return; + } + let { object } = on; + if (object && down.z < 0) { + let { point } = object; + let { widget } = object.object; + supportUpdate(point, widget, removingSupports); + } + }); + + onEventFDM("mouse.hover", data => { + if (!addingSupports) { + return; + } + }); +} + +function clearWidgetSupports(widget) { + delete widget.anno.support; + widget.anno.paint = []; +} + +function rotateWidgetPaint(widget, angle) { + if (!widget.anno.paint?.length) { + return; + } + + // Z rotation angle in radians + const cos = Math.cos(angle); + const sin = Math.sin(angle); + + // Rotate each paint point around the Z axis + for (let paintPoint of widget.anno.paint) { + const { point } = paintPoint; + const x = point.x; + const y = point.y; + point.x = x * cos - y * sin; + point.y = x * sin + y * cos; + } +} + +// start manual add supports +function supportStart({ remove } = { remove: false }) { + if (addingSupports) { + supportDone(); + if (removingSupports === remove) { + return; + } + } + removingSupports = remove; + alert = api.show.alert(`[esc] key cancels ${remove?'removing':'adding'} supports`, 1000); + api.feature.hover = addingSupports = true; + api.feature.on_mouse_down = (int) => { + if (int) { + let { point } = int; + down = { point, widget: int.object.widget, z: int.face.normal.z }; + } + return api.widgets.meshes(); + }; + api.feature.on_mouse_drag = ({ int }) => { + // Calculate if surface needs support based on belt mode or normal mode + let needsSupport = false; + if (int?.length) { + const settings = api.conf.get(); + const normal = int[0].face.normal; + + // Get support angle threshold (how steep before needing support) + const supportAngle = settings.process.sliceSupportAngle || 45; + const supportThreshold = Math.cos((90 - supportAngle) * Math.PI / 180); + + if (settings.device.bedBelt) { + // Belt mode: check if surface will face downward after belt rotation + // After rotating by angle θ around X: nz' = ny*sin(θ) + nz*cos(θ) + // We want nz' < -threshold (within cone of support angle from down) + const angle = (settings.process.sliceAngle || 45) * Math.PI / 180; + const rotatedZ = normal.y * Math.sin(angle) + normal.z * Math.cos(angle); + needsSupport = rotatedZ < -supportThreshold; + } else { + // Normal mode: check if normal points downward within support angle cone + needsSupport = normal.z < -supportThreshold; + } + } + + if (needsSupport) { + supportUpdate(int[0].point, down.widget, remove); + } + return [ down.widget.mesh ]; + // return api.widgets.meshes(); + }; + // Get paint color from scheme + const mode = api.mode.get_id(); + const theme = api.space.is_dark() ? 'dark' : 'light'; + const scheme = colorSchemeRegistry.getScheme(mode, theme); + const paintColor = scheme.operations?.paint?.overlay ?? 0x4488ff; + + // Get belt configuration for paint constraint + const settings = api.conf.get(); + const isBelt = settings.device.bedBelt; + const beltAngle = isBelt ? (settings.process.sliceAngle || 45) * Math.PI / 180 : 0; + const supportAngle = settings.process.sliceSupportAngle || 45; + + api.widgets.each(w => { + // Use pushVisualState to track the paint operation + w.pushVisualState('paint', { + material: w.mesh.material[0], + restoreCallback: () => { + w.mesh.material[0] = w.cache.mat; + delete w.cache.mat; + } + }); + + let mat = w.cache.mat = w.mesh.material[0]; + mat = w.mesh.material[0] = mat.clone(); + mat.needsUpdate = true; + if (!w.anno.paint) { + w.anno.paint = []; + } + addPaintOverlayTexture(mat, w.anno.paint, new THREE.Color(paintColor), { + beltMode: isBelt, + beltAngle, + supportAngle + }); + }) + api.space.update(); +} + +function supportUpdate(point, widget, remove) { + let { x, y, z } = point; + let pos = widget.track.pos; + // translate from three coordinates to widget coordinates + let rec = { + point: { + x: x - pos.x, + y: -z - pos.y, + z: y - pos.z + }, + radius: 2 + }; + // return console.log({ on, widget, point }); + let paint = widget.anno.paint; + let pt = newPoint(x, -z, y); + for (let r of paint) { + let { point } = r; + let dist = newPoint(point.x, point.y, point.z).distTo2D(pt); + if (remove && dist < 2) { + r.delete = true; + } else if (dist < 1) { + return; + } + } + if (remove) { + paint = widget.anno.paint = paint.filter(rec => !rec.delete); + } else { + paint.push(rec); + } + updatePaintOverlay(widget.mesh.material[0], paint); + api.space.update(); + api.conf.save(); +}; + +// manual add done +function supportDone() { + if (!addingSupports) { + return; + } + api.feature.hover = addingSupports = false; + api.feature.on_mouse_down = undefined; + api.feature.on_mouse_drag = undefined; + api.hide.alert(alert); + api.widgets.each(w => { + // Use popVisualState to restore original material + w.popVisualState('paint'); + }); +} + +// manual supports clear +function supportClear() { + supportDone(); + api.widgets.each(w => clearWidgetSupports(w)); + api.conf.save(); +} diff --git a/src/kiri/mode/fdm/client.js b/src/kiri/mode/fdm/client.js deleted file mode 100644 index 466856f9..00000000 --- a/src/kiri/mode/fdm/client.js +++ /dev/null @@ -1,645 +0,0 @@ -/** Copyright Stewart Allen -- All Rights Reserved */ - -import { $ } from '../../../moto/webui.js'; -import { api } from '../../core/api.js'; -import { conf } from '../../core/conf.js'; -import { util } from '../../../geo/base.js'; -import { client } from '../../core/client.js'; -import { addbox, delbox, getlastbox } from '../../core/boxes.js'; -import { Layers } from '../../core/layers.js'; -import { Stack } from '../../core/stack.js'; -import { Widget, newWidget } from '../../core/widget.js'; -import { space } from '../../../moto/space.js'; - -const { Matrix4, Vector3, BufferAttribute, BufferGeometryUtils, Raycaster, Euler } = THREE; -const { VIEWS } = api.const; - -const LANG = api.language.current; - -let lastBox, lastMode, lastView, lastPillar, fromPillar, - addingSupports = false, - isFdmMode = false, - nextID = Date.now(), - p1, p2, iw, - alert = [], - func = {}; - -export function init() { - const { ui } = api; - const proc_keys = Object.keys(conf.defaults.fdm.p); - const rangeVars = { - // slice - // "sliceHeight": LANG.sl_lahi_s, - "sliceShells": LANG.sl_shel_s, - "sliceFillType": LANG.fi_type, - "sliceFillWidth": LANG.fi_wdth_s, - "sliceFillSparse": LANG.fi_pcnt_s, - "sliceFillGrow": LANG.fi_grow_s, - "sliceSolidMinArea": LANG.ad_msol_s, - "sliceTopLayers": LANG.sl_ltop_s, - "sliceBottomLayers": LANG.sl_lbot_s, - "firstLayerRate": LANG.fl_rate_s, - "firstLayerFanSpeed": LANG.ou_fans_s, - // prepare - "sliceShellOrder": LANG.sl_ordr_s, - "sliceFillOverlap": LANG.fi_over_s, - "outputFeedrate": LANG.ou_feed_s, - "outputFinishrate": LANG.ou_fini_s, - "outputShellMult": LANG.ou_shml_s, - "outputFillMult": LANG.ou_flml_s, - "outputSparseMult": LANG.ou_spml_s, - "outputRetractWipe": LANG.ad_wpln_s, - "outputAlternating": LANG.ad_altr_s, - "outputShortPoly": LANG.ad_spol_s, - "outputMinSpeed": LANG.ad_mins_s, - "outputCoastDist": LANG.ad_scst_s, - "outputLoops": LANG.ag_loop_s, - "sliceSupportDensity": LANG.sp_dens_s, - "sliceSupportOffset": LANG.sp_offs_s, - "sliceLayerStart": LANG.sl_strt_s, - "firstLayerBrim": LANG.fl_brim_s, - "firstLayerBrimIn": LANG.fl_brin_s, - "firstLayerBrimTrig": LANG.fl_brmn_s, - "firstLayerBrimGap": LANG.fl_brgp_s, - // export - "zHopDistance": LANG.ad_zhop_s, - "arcTolerance": LANG.ad_zhop_s, - "antiBacklash": LANG.ad_abkl_s, - "outputTemp": LANG.ou_nozl_s, - "outputBedTemp": LANG.ou_bedd_s, - "outputFanSpeed": LANG.ou_fans_s, - "outputRetractDist": LANG.ad_rdst_s, - "outputRetractSpeed": LANG.ad_rrat_s, - "outputRetractDwell": LANG.ad_rdwl_s, - "outputLayerRetract": LANG.ad_lret_s - }; - - for (let key of Object.keys(rangeVars)) { - if (ui[key]) { - ui[key].range = true; - } - } - - function filterSynth() { - api.widgets.filter((widget) => { - if (widget.track.synth) { - space.world.remove(widget.mesh); - Widget.Groups.remove(widget); - } - return !widget.track.synth - }); - } - - function clearRanges() { - api.conf.get().process.ranges = []; - ui.fdmRanges.innerHTML = ''; - } - - // re-render ranges menu - function updateRanges(ranges = []) { - let html = []; - let bind = []; - let now = Date.now(); - let sorted = ranges.sort((a,b) => b.lo - a.lo); - for (let range of sorted) { - let id = (now++).toString(36); - let rows = Object.entries(range.fields).map(a => `
`).join(''); - let hover = `
${rows}
`; - let info = ``; - html.appendAll([ - `
${hover}${info}
` - ]); - bind.push({id, range}); - } - ui.fdmRanges.innerHTML = html.join(''); - for (let rec of bind) { - $(`sel_${rec.id}`).onclick = () => { - api.show.layer(rec.range.hi, rec.range.lo); - }; - $(`del_${rec.id}`).onclick = () => { - let io = ranges.indexOf(rec.range); - ranges.splice(io,1); - updateRanges(ranges); - }; - } - } - - api.event.on("function.animate", (mode) => { - if (!(isFdmMode && lastView === VIEWS.PREVIEW)) { - return; - } - let slider = $('top-slider'); - slider.style.display = 'flex'; - slider.oninput = slider.onchange = (ev) => { - api.const.STACKS.setFraction(parseInt(ev.target.value)/1000); - api.space.update(); - }; - $('anim-slider').value = 500; - api.const.STACKS.setFraction(0.5); - }); - - api.event.on("mode.set", mode => { - isFdmMode = mode === 'FDM'; - lastMode = mode; - // hide/show fdm mode elements - for (let el of [...document.getElementsByClassName('mode-fdm')]) { - api.uc.setClass(el, 'hide', !isFdmMode); - } - updateSupportVisiblity(); - }); - - api.event.on("view.set", view => { - $('top-slider').style.display = 'none'; - lastView = view; - updateSupportVisiblity(); - filterSynth(); - func.sdone(); - // let ranges = api.conf.get().process.ranges; - if (isFdmMode) { - if (lastView === VIEWS.SLICE) { - for (let key of proc_keys) { - if (ui[key] && !ui[key].range) { - ui[key].disabled = true; - } - } - } else { - for (let key of proc_keys) { - if (ui[key]) ui[key].disabled = false; - } - } - } - }); - - api.event.on("range.updates", updateRanges); - - api.event.on("settings.load", (settings) => { - if (settings.mode !== 'FDM') return; - restoreSupports(api.widgets.all()); - updateRanges(settings.process.ranges); - }); - - api.event.on("settings.saved", (settings) => { - updateRanges(settings.process.ranges); - }); - - api.event.on("button.click", target => { - switch (target) { - case api.ui.ssaGen: return func.sgen(); - case api.ui.ssmAdd: return func.sadd(); - case api.ui.ssmDun: return func.sdone(); - case api.ui.ssmClr: return func.sclear(); - } - }); - - api.event.on("fdm.supports.detect", func.sgen = () => { - let alerts = []; - let { process, device } = api.conf.get(); - if (!device.bedBelt) { - if (process.sliceSupportAngle < 10) { - alerts.push(api.show.alert("angles below 10 degrees may fail")); - } - } - alerts.push(api.show.alert("analyzing part(s)...", 1000)); - support_generate(array => { - func.sclear(); - api.hide.alert(undefined,alerts); - for (let rec of array) { - let { widget, supports } = rec; - let wa = api.widgets.annotate(widget.id); - let ws = wa.support || []; - for (let support of supports) { - let { from, to, mid } = support; - let dw = api.conf.get().process.sliceSupportSize / 2; - let dh = from.z - to.z; - let rec = { - x: mid.x, - y: mid.y, - z: mid.z, - dw, - dh, - id: Math.random() * 0xffffffffff - }; - addWidgetSupport(widget, rec); - ws.push(Object.clone(rec)); - } - wa.support = ws; - } - api.event.emit("fdm.supports.detected"); - }); - }); - - api.event.on("fdm.supports.add", func.sadd = () => { - alert = api.show.alert("[esc] key cancels support editing"); - api.feature.hover = addingSupports = true; - }); - - api.event.on("fdm.supports.done", func.sdone = () => { - delbox('intZ'); - delbox('intW'); - delbox('supp'); - api.hide.alert(alert); - api.feature.hover = addingSupports = false; - fromPillar = undefined; - }); - - api.event.on("fdm.supports.clear", func.sclear = () => { - func.sdone(); - if (clearAllWidgetSupports()) { - api.conf.save(); - } - }); - - api.event.on("slice.begin", () => { - if (!isFdmMode) { - return; - } - func.sdone(); - updateSupportVisiblity(); - // synth support widget for each widget group - // iow, create support meshes based on support widget annotation - for (let group of Widget.Groups.list()) { - let merge = group.filter(w => w.sups).map(w => Object.values(w.sups)).flat(); - if (!merge.length) { - continue; - } - let boxen = merge.map(m => { - let geo = m.box.geometry.clone(); - if (geo.index) geo = geo.toNonIndexed(); - return geo.translate(m.x, m.y, m.z); - }); - for (let box of boxen) { - // eliminate / normalize uv to allow other widget merge - box.setAttribute('uv',new BufferAttribute(new Float32Array(0), 3)); - } - let bbg = BufferGeometryUtils.mergeGeometries(boxen); - let sw = newWidget(null, group); - let fwp = group[0].track.pos; - sw.loadGeometry(bbg); - sw._move(fwp.x, fwp.y, fwp.z); - api.widgets.add(sw); - sw.track.synth = true; - sw.track.support = true; - space.world.add(sw.mesh); - } - }); - - api.event.on("slice.end", () => { - if (!isFdmMode) { - return; - } - }); - - api.event.on("key.esc", () => { - if (isFdmMode) { - func.sdone() - } - }); - - api.event.on("selection.scale", () => { - if (isFdmMode) { - clearRanges(); - func.sclear(); - } - }); - - api.event.on("widget.delete", widget => { - if (isFdmMode) { - clearRanges(); - } - }); - - api.event.on("widget.duplicate", (widget, oldwidget) => { - if (!isFdmMode) { - return; - } - let ann = api.widgets.annotate(widget.id); - if (ann.support) { - for (let supp of Object.values(ann.support)) { - supp.id = Math.abs(Math.random() * 0xffffffffffff); - } - restoreSupports([widget]); - } - }); - - api.event.on("widget.mirror", widget => { - if (!isFdmMode) { - return; - } - let ann = api.widgets.annotate(widget.id); - let sups = ann.support || []; - sups.forEach(sup => { - let wsup = widget.sups[sup.id]; - wsup.box.position.x = wsup.x = sup.x = -sup.x; - }); - }); - - api.event.on("widget.rotate", rot => { - if (!isFdmMode) { - return; - } - let {widget, x, y, z} = rot; - if (x || y) { - clearWidgetSupports(widget); - } else { - let ann = api.widgets.annotate(widget.id); - let sups = ann.support || []; - sups.forEach(sup => { - let wsup = widget.sups[sup.id]; - let vc = new Vector3(sup.x, sup.y, sup.z); - let m4 = new Matrix4(); - m4 = m4.makeRotationFromEuler(new Euler(x || 0, y || 0, z || 0)); - vc.applyMatrix4(m4); - wsup.box.position.x = wsup.x = sup.x = vc.x; - wsup.box.position.y = wsup.y = sup.y = vc.y; - wsup.box.position.z = wsup.z = sup.z = vc.z; - }); - } - }); - - api.event.on("mouse.hover.up", func.hoverup = (on = {}) => { - let { event } = on; - if (!isFdmMode) { - return; - } - if (!addingSupports) { - return; - } - delbox('supp'); - if (lastPillar) { - removeWidgetSupport(lastPillar.widget, lastPillar); - return; - } - lastBox = getlastbox(); - if (!(iw && lastBox)) return; - let { point, dim } = lastBox; - p1.y = Math.max(0, p1.y); - p2.y = Math.max(0, p2.y); - let rz = dim.rz; - let dh = dim.z; - let dw = api.conf.get().process.sliceSupportSize / 2; - let ip = iw.track.pos; - let wa = api.widgets.annotate(iw.id); - let ws = (wa.support = wa.support || []); - let id = ++nextID; - let rec = {x: point.x - ip.x, y: -point.z - ip.y, z: point.y, rz, dw, dh, id}; - if (fromPillar && event && event.shiftKey) { - let targets = api.widgets.meshes().append(space.internals().platform); - let from = fromPillar, - d = { - x: rec.x - from.x, - y: rec.y - from.y, - z: rec.z - from.z - }; - let dist = Math.sqrt(d.x*d.x + d.y*d.y + d.z*d.z); - let lerp = util.lerp(0,dist,dw).map(v => v/dist); - let angle = rec.rz = Math.atan2(d.y,d.x); - let fromrec = ws.filter(r => r.id === from.id)[0]; - if (fromrec) { - from.rz = fromrec.rz = angle; - removeWidgetSupport(iw, fromPillar); - ws.push(Object.clone(fromrec)); - addWidgetSupport(iw, fromrec); - } - lerp.pop(); - let seed = Math.random(); - for (let pct of lerp) { - let point = new Vector3(from.x + d.x * pct, from.z + d.z * pct, from.y + d.y * pct); - addSupportAtPoint(targets, point, ip, angle, seed++); - } - } - ws.push(Object.clone(rec)); - fromPillar = addWidgetSupport(iw, rec); - api.conf.save(); - }); - - function addSupportAtPoint(targets, point, ip, angle, id) { - let up = new Vector3(0,1,0) - let dn = new Vector3(0,-1,0) - let rp = new Vector3(point.x + ip.x, point.y, -point.z - ip.y); - let iup = new Raycaster(rp, up) - .intersectObjects(targets, false) - .filter(t => !t.object.pillar); - if (iup.length && iup.length % 2 === 0) { - let idn = new Raycaster(rp, dn) - .intersectObjects(targets, false) - .filter(t => !t.object.pillar); - if (idn.length) { - iw = iup[0].object.widget || iw; - let wa = api.widgets.annotate(iw.id); - let ws = (wa.support = wa.support || []); - let phi = iup[0].point; - let plo = idn[0].point; - let ply = Math.max(0, plo.y); - let mp = (phi.y + ply) / 2; - let dy = Math.abs(phi.y - ply); - let dw = api.conf.get().process.sliceSupportSize / 2; - let rec = { rz:angle, x:point.x, y:point.z, z:mp, dw, dh:dy, id }; - ws.push(Object.clone(rec)); - fromPillar = addWidgetSupport(iw, rec); - } - } - } - - api.event.on("mouse.hover", data => { - if (!isFdmMode) { - return; - } - if (!addingSupports) { - return; - } - delbox('supp'); - const { int, type, point, event } = data; - const pillar = int ? int.object.pillar : undefined; - if (lastPillar) { - lastPillar.box.material.color.r = 0; - lastPillar = null; - } - if (pillar) { - pillar.box.material.color.r = 0.5; - lastPillar = pillar; - if (event && (event.metaKey || event.ctrlKey)) { - return func.hoverup(); - } - return; - } - if (int && type === 'widget') { - iw = int.object.widget || iw; - } else { - iw = null; - } - p1 = point; - // looking up by default (assumes platform intersect) - let dir = new Vector3(0,1,0) - let ray = new Raycaster(point, dir); - let rz = int && int.face ? Math.atan2(int.face.normal.y, int.face.normal.x) : 0; - // when intersecting underside of object, project down - if (int && int.face && int.face.normal.z < -0.1) { - dir.y = -1; - } - let targets = api.widgets.meshes().append(space.internals().platform); - let i2 = ray.intersectObjects(targets, false) - .filter(t => !t.object.pillar) // eliminate other pillars - .filter(i => i.distance > 0.1); // false matches close to origin of ray - if (i2.length) { - p2 = i2[0].point; - iw = i2[0].object.widget || iw; - let p1y = Math.max(0, p1.y); - let p2y = Math.max(0, p2.y); - let hy = (p1y + p2y) / 2; - let dy = Math.abs(p1y - p2y); - let dw = api.conf.get().process.sliceSupportSize / 2; - addbox({x:p1.x, y:hy, z:p1.z}, 0x0000dd, 'supp', { - x:dw, y:dw, z:dy, rz - }); - } else { - lastBox = null; - } - if (event && event.altKey) { - return func.hoverup(); - } - }); - - api.event.on("export.debug", msg => { - if (msg.arcQ && msg.arcQ.length > 2) { - if (xpdebug) { - xpdebug.destroy(); - xpdebug = undefined; - } - let poly = base.newPolygon().setOpen(); - for (let rec of msg.arcQ) { - rec.z += 1; - poly.addObj(rec); - } - - let layers = new Layers(); - let layer = layers.setLayer("arcq", { line: 0xff00ff, face: 0xff00ff, opacity: 1 }); - layer.addPoly(poly, {thin:true}); - - layer = layers.setLayer("arcc", { line: 0xff0000, face: 0xff0000, opacity: 1 }); - for (let center of msg.arcQ.center) { - center.z += 0.5; - layer.addPoly(base.newPolygon().centerCircle( - center, center.r, 20 - )); - } - - xpdebug = new Stack(space.world, false); - xpdebug.addLayers(layers); - xpdebug.setVisible(0,Infinity); - xpdebug.show(); - - space.refresh(); - - console.log(msg, poly); - } - }); -} - -let xpdebug; -let supsave = {}; -let suptimer; - -function support_generate(ondone) { - client.clear(); - client.sync(); - const settings = api.conf.get(); - const widgets = api.widgets.map(); - client.send("fdm_support_generate", { settings }, (gen) => { - if (gen && gen.error) { - api.show.alert('support generation canceled'); - return ondone([]); - } - for (let g of gen) { - g.widget = widgets[g.id]; - } - ondone(gen); - }); -}; - -function scheduleSupportSave(widget) { - clearTimeout(suptimer); - supsave[widget.id] = widget; - suptimer = setTimeout(() => { - for (let w of Object.values(supsave)) { - w.saveState(); - } - }, 50); -} - -export function restoreSupports(widgets) { - widgets.forEach(widget => { - const supports = api.widgets.annotate(widget.id).support || []; - supports.forEach(pos => { - addWidgetSupport(widget, pos); - }); - }); -} - -function addWidgetSupport(widget, pos) { - const { x, y, z, rz, dw, dh, id } = pos; - const sups = widget.sups = (widget.sups || {}); - // prevent duplicate restore from repeated settings load calls - if (!sups[id]) { - pos.box = addbox( - { x, y, z }, 0x0000dd, id, - { x:dw, y:dw, z:dh, rz }, { group: widget.mesh } - ); - pos.box.pillar = Object.assign({widget}, pos); - sups[id] = pos; - widget.adds.push(pos.box); - scheduleSupportSave(widget); - } - return sups[id]; -} - -function removeWidgetSupport(widget, rec) { - const { box, id } = rec; - widget.adds.remove(box); - widget.mesh.remove(box); - delete widget.sups[id]; - let sa = api.widgets.annotate(widget.id).support; - let ix = 0; - sa.forEach((rec,i) => { - if (rec.id === id) { - ix = i; - } - }); - sa.splice(ix,1); - api.conf.save(); - scheduleSupportSave(widget); - fromPillar = undefined; -} - -function updateSupportVisiblity() { - const visible = (lastMode === 'FDM' && lastView === VIEWS.ARRANGE); - for (let w of api.widgets.all()) { - setSupportVisiblity(w, visible); - } -} - -function setSupportVisiblity(widget, bool) { - Object.values(widget.sups || {}).forEach(support => { - support.box.visible = bool; - }); -} - -function clearAllWidgetSupports() { - let cleared = 0; - api.widgets.all().forEach(widget => { - cleared += clearWidgetSupports(widget); - }); - return cleared; -} - -function clearWidgetSupports(widget) { - let cleared = 0; - Object.values(widget.sups || {}).forEach(support => { - widget.adds.remove(support.box); - widget.mesh.remove(support.box); - cleared++; - }); - widget.sups = {}; - delete api.widgets.annotate(widget.id).support; - scheduleSupportSave(widget); - return cleared; -} diff --git a/src/kiri/mode/fdm/driver.js b/src/kiri/mode/fdm/core/params.js similarity index 100% rename from src/kiri/mode/fdm/driver.js rename to src/kiri/mode/fdm/core/params.js diff --git a/src/kiri/mode/fdm/driver-be.js b/src/kiri/mode/fdm/driver-be.js deleted file mode 100644 index 7a14bb39..00000000 --- a/src/kiri/mode/fdm/driver-be.js +++ /dev/null @@ -1,47 +0,0 @@ -/** Copyright Stewart Allen -- All Rights Reserved */ - -import { util } from '../../../geo/base.js'; -import { fdm_slice, sliceAll, supports } from './slice.js'; -import { fdm_prepare } from './prepare.js'; -import { fdm_export } from './export.js'; -import { codec } from '../../core/codec.js'; - -// noz = nozzle diameter -// fil = filament diameter -// slice = slice height -function extrudePerMM(noz, fil, slice) { - // (ratio of nozzle to filament) * (ratio of slice to nozzle) - return ((Math.PI * util.sqr(noz / 2)) / (Math.PI * util.sqr(fil / 2))) * (slice / noz); -}; - -// dist = distance between extrusion points -// perMM = amount extruded per MM (from extrudePerMM) -// factor = scaling factor (usually 1.0) -function extrudeMM(dist, perMM, factor) { - return dist * perMM * factor; -} - -// defer loading until client and worker exist -function init(worker) { - - worker.dispatch.fdm_support_generate = function(data, send) { - const { settings } = data; - const widgets = Object.values(worker.cache); - const fresh = widgets.filter(widget => supports(settings, widget)); - send.done(codec.encode(fresh.map(widget => { return { - id: widget.id, - supports: widget.supports, - } } ))); - }; - -} - -export const FDM = { - init, - extrudePerMM, - extrudeMM, - slice: fdm_slice, - sliceAll: sliceAll, - prepare: fdm_prepare, - export: fdm_export -}; diff --git a/src/kiri/mode/fdm/slice.js b/src/kiri/mode/fdm/slice.js deleted file mode 100644 index 5bc215da..00000000 --- a/src/kiri/mode/fdm/slice.js +++ /dev/null @@ -1,1959 +0,0 @@ -/** Copyright Stewart Allen -- All Rights Reserved */ - -import { base, util } from '../../../geo/base.js'; -import { newBounds } from '../../../geo/bounds.js'; -import { newPolygon } from '../../../geo/polygon.js'; -import { polygons as POLY } from '../../../geo/polygons.js'; -import { newPoint } from '../../../geo/point.js'; -import { slice, sliceZ } from '../../../geo/slicer.js'; -import { newSlice } from '../../core/slice.js'; -import { fill, fill_fixed } from './fill.js'; -import { getRangeParameters } from './driver.js'; -import { doTopShells } from './post.js'; - -const { config } = base; - -let tracker = util.pwait, - lopacity = 0.6, - opacity = 1, - fat = 1.5, - COLOR = { - anchor: { check: 0x999933, face: 0x999933, line: 0x999933, opacity, lopacity, fat }, - fill: { check: 0x00bb77, face: 0x00bb77, line: 0x00bb77, opacity, lopacity, fat }, - gaps: { check: 0xaa3366, face: 0xaa3366, line: 0xaa3366, opacity, lopacity, fat }, - infill: { check: 0x3322bb, face: 0x3322bb, line: 0x3322bb, opacity, lopacity, fat }, - inset: { line: 0xaaaaaa, check: 0xaaaaaa, face: 0 }, - shell: { check: 0x0077bb, face: 0x0077bb, line: 0x0077bb, opacity, lopacity, fat }, - support: { check: 0xaa5533, face: 0xaa5533, line: 0xaa5533, opacity, lopacity, fat }, - part: { line: 0x333333, check: 0x333333 }, - thin: { check: 0xbb8800, face: 0xbb8800, line: 0xbb8800, opacity, lopacity, fat }, - }, - COLOR_DARK = Object.assign({}, COLOR, { - fill: { check: 0x008855, face: 0x008855, line: 0x008855, opacity, lopacity, fat }, - infill: { check: 0x3322bb, face: 0x3322bb, line: 0x3322bb, opacity, lopacity, fat }, - inset: { line: 0x555555, check: 0x555555 }, - part: { line: 0x777777, check: 0x777777, face: 0 }, - thin: { check: 0xbb8800, face: 0xbb8800, line: 0xbb8800, opacity, lopacity, fat }, - }), - noop = () => {}, - PROTO = Object.clone(COLOR), - profile = false, - profileStart = profile ? console.profile : noop, - profileEnd = profile ? console.profileEnd : noop, - debug = false; - -let isThin = false, // force line rendering - isFlat = false, // force flat rendering - offset = 0; // poly line generation offsets - -function vopt(opt) { - if (opt) { - if (isFlat) { - opt.flat = true; - opt.outline = true; - return opt; - } - if (isThin) return null; - } - return opt; -} - -export function sliceAll(settings, onupdate) { - // future home of brim and anchor generation - let widgets = Object.values(self.kiri_worker.cache) - .filter(w => !w.meta.disabled) - .sort((a,b) => { - return a.slices[0].z - b.slices[0].z - }); - // assign grid_id which can be embedded in gcode and - // used by the controller to cancel objects during print - let { bounds } = settings; - for (let widget of widgets) { - let { pos, box } = widget.track; - // calculate top/left coordinate for widget - // relative to bounding box for all widgets - let tl = { - x: Math.round((pos.x - box.w/2 - bounds.min.x) / 10) + 1, - y: Math.round((pos.y - box.h/2 - bounds.min.y) / 10) + 1 - }; - widget.track.grid_id = tl.x * 100 + tl.y; - } - // count extruders used - let ext = []; - for (let w of widgets) { - if (w.anno && w.anno.extruder >= 0) { - let e = w.anno.extruder; - if (ext.indexOf(e) < 0) { - ext.push(e); - } - } - } - // sort widgets by first slice Z - widgets.sort((a,b) => { - return a.slices[0].z - b.slices[0].z; - }); - // give first widget a pass since it should have the anchor - widgets.shift(); - // remove anchor slices from other widgets (only with multi-material) - if (ext.length > 1) { - for (let w of widgets) { - w.slices = w.slices.filter(s => s.index >= 0); - } - } -}; - -/** - * DRIVER SLICE CONTRACT - * - * Given a widget and settings object, call functions necessary to produce - * slices and then the computations using those slices. This function is - * designed to run client or server-side and provides all output via - * callback functions. - * - * @param {Object} settings - * @param {Widget} Widget - * @param {Function} onupdate (called with % complete and optional message) - * @param {Function} ondone (called when complete with an array of Slice objects) - */ -export function fdm_slice(settings, widget, onupdate, ondone) { - let render = settings.render !== false, - { minions } = self.kiri_worker, - { process, device, controller } = settings, - isBelt = device.bedBelt, - isBrick = controller.devel && process.sliceZInterleave, - isSynth = widget.track.synth, - isSupport = widget.track.support, - useAssembly = controller.assembly, - isConcurrent = controller.threaded && minions.concurrent, - topLayers = process.sliceTopLayers || 0, - bottomLayers = process.sliceBottomLayers || 0, - vaseMode = process.sliceFillType === 'vase' && !isSynth, - metadata = widget.anno, - maxtruder = Math.max(0, device.extruders.length - 1), - extruder = Math.min(maxtruder, parseInt(isSynth ? process.sliceSupportNozzle : metadata.extruder || 0)), - sliceHeight = process.sliceHeight, - sliceHeightBase = (isBelt ? sliceHeight : process.firstSliceHeight) || sliceHeight, - lineWidth = process.sliceLineWidth || device.extruders[extruder].extNozzle, - fillOffsetMult = 1.0 - bound(process.sliceFillOverlap, 0, 0.8), - shellOffset = lineWidth, - fillSpacing = lineWidth, - fillOffset = lineWidth * fillOffsetMult, - clipOffset = process.sliceSupportOffset, - sliceFillAngle = process.sliceFillAngle, - supportDensity = process.sliceSupportDensity, - { sliceCompInner, sliceCompOuter } = process; - - // override globals used by vopt() - isFlat = controller.lineType === "flat"; - isThin = !isFlat && controller.lineType === "line"; - offset = lineWidth / 2; - - // allow overriding support fill auto angle algorithm - // also causes support fill to be aligned on start boundaries - // best with angles that are a multiple of 90 degrees - if (process.sliceSupportFill >= 0) { - // yes, an ugly hack to allow it to pass through old code paths - process.sliceSupportFill += 1000; - } - - if (isFlat) { - Object.values(COLOR).forEach(color => { - color.flat = true; - color.line = 1 - color.opacity = 1; - }); - } else { - Object.keys(COLOR).forEach(key => { - const color = COLOR[key]; - const proto = PROTO[key] - color.flat = proto.flat; - color.line = proto.line; - color.opacity = proto.opacity; - }); - } - - if (!(sliceHeight > 0 && sliceHeight < 100)) { - return ondone("invalid slice height"); - } - if (!(lineWidth >= 0.01 && lineWidth < 100)) { - return ondone("invalid nozzle size"); - } - - const sliceMinHeight = process.sliceAdaptive && process.sliceMinHeight > 0 ? - Math.min(process.sliceMinHeight, sliceHeight) : 0; - - if (sliceHeightBase <= 0) { - console.log("invalid first layer height < slice height"); - console.log("reverting to min valid slice height"); - sliceHeightBase = sliceMinHeight || sliceHeight; - } - - let bounds = widget.getBoundingBox(); - let points = widget.getPoints(); - let indices = []; - let heights = []; - let healed = false; - - // handle z cutting (floor method) and base flattening - let zPress = isBelt ? process.firstLayerFlatten || 0 : 0; - let zCut = widget.track.zcut || 0; - let { belt } = widget; - if (zCut || zPress) { - for (let p of points) { - if (!p._z) { - p._z = p.z; - if (zPress) { - if (isBelt) { - let zd = (belt.slope * p.z) - p.y; - if (zd > 0 && zd <= zPress) { - p.y += zd * belt.cosf; - p.z -= zd * belt.sinf; - } - } else { - if (p.z <= zPress) p.z = 0; - } - } - if (zCut && !isBelt) { - p.z -= zCut; - } - } - } - } - - slice(points, { - debug: process.xray, - xray: process.xray, - zMin: bounds.min.z, - zMax: bounds.max.z - zCut, - // support/synth usually has overlapping boxes - union: controller.healMesh || isSynth, - indices: process.indices || process.xray, - useAssembly, - post: 'FDM', - post_args: { - compInner: sliceCompInner, - compOuter: sliceCompOuter, - shellOffset, - fillOffset, - clipOffset, - lineWidth, - vaseMode, - isSynth, - process, - }, - // z index generator - zGen(zopt) { - if (process.xray) { - return zopt.zIndexes; - } - let { zMin, zMax } = zopt; - let h1 = sliceHeight; - let h0 = sliceHeightBase || h1; - let hm = sliceMinHeight || 0; - let h = h0; - let z = h0; - let zi = indices; // indices - let zh = heights; // heights - if (hm) { - // adaptive increments based on z indices (var map to legacy code) - let zIncFirst = h0; - let zInc = h1; - let zIncMin = hm; - let zHeights = heights; - let zIndexes = indices; - let zOrdered = Object.values(zopt.zIndexes).map(v => parseFloat(v)); - // console.log('adaptive slicing', zIncMin, ':', zInc, 'from', zMin, 'to', zMax); - let zPos = zIncFirst, - zOI = 0, - zDelta, - zDivMin, - zDivMax, - zStep, - nextZ, - lzp = zPos; - // adaptive slice height - // first slice/height is fixed from base - zHeights.push(zIncFirst); - zIndexes.push(zIncFirst); - // console.log({zIncFirst, zOrdered}) - while (zPos < zMax && zOI < zOrdered.length) { - nextZ = zOrdered[zOI++]; - if (zPos >= nextZ) { - // console.log('skip',{zPos},'>=',{nextZ}); - continue; - } - zDelta = nextZ - zPos; - if (zDelta < zIncMin) { - // console.log('skip',{zDelta},'<',{zIncMin}); - continue; - } - zDivMin = Math.floor(zDelta / zIncMin); - zDivMax = Math.floor(zDelta / zInc); - if (zDivMax && zDivMax <= zDivMin) { - if (zDelta % zInc > 0.01) zDivMax++; - zStep = zDelta / zDivMax; - // console.log(`--- zDivMax <= zDivMin ---`, zStep, zDelta % zInc) - } else { - zStep = zDelta; - } - // console.log({nextZ, zPos, zDelta, zStep, zDivMin, zDivMax}) - while (zPos < nextZ) { - zHeights.push(zStep); - zIndexes.push(zPos + zStep); - zPos += zStep; - // console.log({D: zPos - lzp, zPos}) - // lzp = zPos; - } - } - // console.log({zIndexes, zHeights}); - } else { - // simple based + fixed increment - while (true) { - // reduce slice position by half layer height - let realz = (z - (h / 2)).round(3); - if (realz > zMax) { - break; - } - zh.push(h); - zi.push(realz); - h = h1; - z += h; - } - } - return zi; - }, - // slicer function (worker local or minion distributed) - slicer(z, points, opts) { - // opts.debug = opts.debug || isSynth; - return (isConcurrent ? minions.sliceZ : sliceZ)(z, points, opts); - }, - onupdate(update) { - return onupdate(0.0 + update * 0.5) - } - }).then((output) => { - // post process slices and re-incorporate missing meta-data - return output.slices.map(data => { - let { z, clip, lines, groups, changes } = data; - if (!data.tops) return null; - let slice = newSlice(z).addTops(data.tops); - slice.index = indices.indexOf(z); - slice.height = heights[slice.index]; - slice.clips = clip; - // do not warn on merging supports - if (changes && !isSynth) { - healed = true; - slice.changes = changes; - if (self.debug) { - console.log('slice healed', slice.index, slice.z, changes); - } - } - if (process.xray) { - slice.index = process.xray.shift(); - slice.lines = lines; - slice.groups = groups; - slice.xray = process.xray; - } - return slice; - }).filter(s => s); - }).then(slices => { - return onSliceDone(slices); - }).then(ondone); - - // shadow used to clip supports in non-belt mode - async function doShadow(slices) { - if (widget.shadow) { - return; - } - let root = widget.group[0]; - if (root.shadow) { - widget.shadow = root.shadow; - return; - } - // console.log({ doShadow: widget, slices }); - // create shadow for clipping supports - let alltops = widget.group - .filter(w => !w.track.synth) // no supports in shadow - .map(w => w.slices).flat() - .map(s => s.tops).flat().map(t => t.simple); - let shadow = isConcurrent ? - await minions.union(alltops, 0.1) : - POLY.union(alltops, 0.1, true); - // expand shadow when requested (support clipping) - if (process.sliceSupportExtra) { - shadow = POLY.offset(shadow, process.sliceSupportExtra); - } - widget.shadow = root.shadow = POLY.setZ(shadow, 0); - // slices[0].output() - // .setLayer('shadow', { line: 0xff0000, check: 0xff0000 }) - // .addPolys(shadow); - } - - async function onSliceDone(slices) { - // alert non-manifold parts - if (healed) { - onupdate(null, null, "part may not be manifold"); - } - - // remove all empty slices above part but leave below - // for multi-part (multi-extruder) setups where the void is ok - // also reverse because slicing occurs bottom-up - let found = false; - slices = slices.reverse().filter(slice => { - if (slice.tops.length) { - return found = true; - } else { - return found; - } - }).reverse(); - - // connect slices into linked list for island/bridge projections - for (let i=1; i 0.1) { - continue; - } - let clipto = pslice.clips; - if (pslice.supportOutline) { - // merge support outlines into slice clips which - // should only happen when automatic and manual - // supports are used together - clipto.appendAll(pslice.supportOutline); - } - let ntops = []; - POLY.subtract(tops, clipto, ntops, null, slice.z, 0); - tops = ntops; - } - // trim to group's shadow if not in belt mode - if (!isBelt) { - tops = POLY.setZ(POLY.trimTo(tops, widget.shadow), slice.z); - } - } - slice.tops = []; - for (let t of tops) { - slice.addTop(t); - } - doShells(slice, 1, shellOffset / 2); - } - } - - // calculate % complete and call onupdate() - function doupdate(index, from, to, msg) { - trackupdate(index / slices.length, from, to, msg); - } - - function trackupdate(pct, from, to, msg) { - onupdate(0.5 + (from + (pct * (to - from))) * 0.5, msg); - } - - // for each slice, performe a function and call doupdate() - function forSlices(from, to, fn, msg) { - slices.forEach(slice => { - fn(slice); - doupdate(slice.index, from, to, msg) - }); - } - - // do not hint polygon fill longer than a max span length - config.hint_len_max = util.sqr(process.sliceBridgeMax); - - // reset for solids, support projections - // and other annotations - slices.forEach(slice => { - slice.widget = widget; - slice.extruder = extruder; - slice.solids = []; - }); - - // just the top/bottom special solid layers or range defined solid layers - forSlices(0.15, 0.2, slice => { - let range = slice.params; - let isBottom = slice.index < bottomLayers; - let isTop = topLayers && slice.index > slices.length - topLayers - 1; - let isTopBase = isTop && slice.index === slices.length - topLayers; - let isDense = range.sliceFillSparse > 0.995; - let isSolid = (isBottom || ((isTop || isDense) && !vaseMode)) && !isSynth; - let solidWidth = isSolid ? range.sliceFillWidth || 1 : 0; - if (solidWidth) { - let fillSpace = fillSpacing * solidWidth; - doSolidLayerFill(slice, fillSpace, sliceFillAngle); - } - if (slice.index === slices.length - 1) { - slice.isFlatsLayer = true; - } - if (isTopBase) { - // mark the first top solid supporting layer as a bridge - slice.isBridgeLayer = true; - } - sliceFillAngle = (sliceFillAngle + 90.0) % 360; - }, "solid layers"); - - // add lead in anchor when specified in belt mode (but not for synths) - if (isBelt && !isSynth) { - let { cosf, slope } = widget.belt; - // find adjusted zero point from slices - let smin = Infinity; - for (let slice of slices) { - let miny = Infinity; - for (let poly of slice.topPolys()) { - let y = poly.bounds.maxy; - let z = slice.z; - // at 45 degrees, 1mm in Z is 1mm in Y - let by = (slope * z) - y; - if (by < miny) miny = by; - if (by < smin) smin = by; - } - // mark slices with tops touching belt - slice.belt = { miny, touch: miny.round(3) < sliceHeightBase }; - } - // find max width of first 5 layers for brim additions - let start; - let minx = Infinity, maxx = -Infinity; - let peek = 0; - for (let slice of slices) { - if (slice.tops.length && peek++ < 5) { - for (let poly of slice.topPolys()) { - minx = Math.min(minx, poly.bounds.minx); - maxx = Math.max(maxx, poly.bounds.maxx); - } - } - // find first slice touching belt for start of anchor - if (!start && slice.belt.touch) { - start = slice; - } - } - // ensure we start against a layer with shells - while (start && start.up && start.topShells().length === 0) { - start = start.up; - } - // if a brim applies, add that width to anchor - let brim = getRangeParameters(process, 0).firstLayerBrim || 0; - if (brim) { - minx -= brim; - maxx += brim; - } - // array of added top.fill_sparse arrays - let adds = []; - let step = sliceHeight; - let anchorlen = (process.beltAnchor || process.firstLayerBeltLead) * cosf; - while (anchorlen && start && anchorlen >= sliceHeight) { - let addto = start.down; - if (!addto) { - addto = newSlice(start.z - step); - addto.extruder = extruder; - addto.belt = { }; - addto.height = start.height; - addto.up = start; - start.down = addto; - slices.splice(0,0,addto); - } else if (!addto.belt) { - console.log({addto_missing_belt: addto}); - addto.belt = {}; - } - addto.index = -1; - addto.belt.anchor = true; - // this allows the anchor to print bi-directionally - // by removing the forced start-point in print.js - addto.belt.touch = false; - let z = addto.z; - let y = (slope * z) - smin - (lineWidth / 2); - let splat = newPolygon().add(minx, y, z).add(maxx, y, z).setOpen(); - let snew = addto.addTop(splat).fill_sparse = [ splat ]; - adds.push(snew); - start = addto; - anchorlen -= (step * slope); - } - // add anchor bump - let bump = process.firstLayerBeltBump; - if (bump) { - adds = adds.reverse().slice(1, adds.length - 1); - let count = 1; - for (let add of adds) { - let poly = add[0]; - let y = count++ * -start.height * 2; - if (-y > bump) { - count--; - // break; - } - let first = poly.first(); - // add up/over/down to anchor line (close = down) - // which completes the bump perimeter - poly.push(poly.last().add({x:0, y, z:0})); - poly.push(poly.first().add({x:0, y, z:0})); - poly.setClosed(); - if (count > 2 && maxx - minx > 10) { - // add vertical hatch lines insibe bump shell - let mp = (maxx + minx) / 2; - let dx = (maxx - minx - 2); - dx = (Math.floor(dx / 3) * 3) / 2; - let fy = first.y; - let fz = first.z; - let n2 = lineWidth / 2; - for (let x = mp - dx; x <= mp + dx ; x += 3) { - add.push(newPolygon().add(x, fy - n2, fz).add(x, fy + y + n2, fz).setOpen() ); - } - } - } - } - // experimental emboss text on a flat underside of an object - // in belt mode only - if (process.pooch && self.OffscreenCanvas) { - const { length, width, height, text } = process.pooch; - let firstZ, firstI, lastZ, lastI, minX = 0, maxX = 0, maxY = 0; - // locate suitable flat spot - for (let slice of slices) { - let { belt } = slice; - if (!belt.touch) { - continue; - } - let index = slice.index; - let z = slice.z; - outer: for (let poly of slice.topPolys()) { - for (let i=0, p=poly.points, l=p.length; i 0 && p0y < 3 && Math.abs(p0y - p1y) < 0.01; - let x_ok = Math.abs(width - Math.abs(p1.x - p0.x)) < 1 - if (y_ok && x_ok) { - if (i_ok) { - firstZ = firstZ || z; - firstI = firstI || index; - lastZ = z; - lastI = index; - minX = Math.min(p0.x, p1.x); - maxX = Math.max(p0.x, p1.x); - maxY = p0y; - break outer; - } else { - firstZ = lastI = 0; - } - } - } - } - } - let dy = Math.abs(height - maxY * (1 / Math.sqrt(2))); - let dz = Math.abs(length - ((lastZ - firstZ) * Math.sqrt(2))); - if (dy < 0.1 && dz < 1) { - // console.log('FOUND', { firstI, lastI, minX, maxX, maxY }); - let span = lastI - firstI - 2; // x = down the belt - let tall = width * 2; - let can = new self.OffscreenCanvas(span, tall); - let ctx = can.getContext("2d"); - ctx.scale(1.2, 1); - ctx.font = '24px sans-serif'; - // ctx.fillStyle = 'black'; - ctx.textBaseline = "bottom"; - ctx.fillText(text, 1, tall - 1); - let img = ctx.getImageData(0, 0, span, tall).data.buffer; - let rgb = new Uint32Array(img); - // console.log({ img, rgb, p: rgb.filter(v => v) }); - for (let x=0; x=0; y--) { - let pix = rgb[y * span + x]; - pix = ( - ((pix >> 24) & 0xff) + - ((pix >> 16) & 0xff) + - ((pix >> 8) & 0xff) - ) / 3; - str += pix > 30 ? '*' : '-'; - maxp = Math.max(maxp, pix); - if (pix > 30) { - if (start >= 0) { - end = tall - y; - } else { - start = tall - y; - } - } else { - if (start >= 0 && end > start) { - lines.push({ start, end }); - } - start = end = undefined; - } - } - console.log((x).toString().padStart(2,0),str,maxp | 0,lines); - if (lines.length) { - let slice = slices[firstI + x + 1]; - let supps = slice.supports = slice.supports || []; - let z = slice.z; - let y = z - smin - (lineWidth / 2); - for (let line of lines) { - supps.push(newPolygon() - .add(minX + line.start / 2, y, z) - .add(minX + line.end / 2, y, z) - .setOpen() - ); - } - } - } - } - } - } - - // calculations only relevant when solid layers are used - // layer boolean diffs need to be computed to find flat areas to fill - // and overhangs that need to be supported. these are stored in flats - // and bridges, projected up/down, and merged into an array of solids - if (!vaseMode && !isSynth) { - profileStart("delta"); - forSlices(0.2, 0.34, slice => { - let params = slice.params || process; - let solidMinArea = params.sliceSolidMinArea; - let sliceFillGrow = params.sliceFillGrow; - doDiff(slice, { min: solidMinArea, grow: sliceFillGrow }); - }, "layer deltas"); - profileEnd(); - profileStart("delta-project"); - forSlices(0.34, 0.35, slice => { - let params = slice.params || process; - topLayers = params.sliceTopLayers || 0; - bottomLayers = params.sliceBottomLayers || 0; - if (topLayers) projectFlats(slice, topLayers); - if (bottomLayers) projectBridges(slice, bottomLayers); - }, "layer deltas"); - profileEnd(); - profileStart("solid-fill") - let promises = isConcurrent ? [] : undefined; - forSlices(0.35, promises ? 0.4 : 0.5, slice => { - let params = slice.params || process; - let first = slice.index === 0; - let solidWidth = params.sliceFillWidth || 1; - let fillSpace = fillSpacing * solidWidth; - let solidMinArea = params.sliceSolidMinArea; - doSolidsFill(slice, fillSpace, sliceFillAngle, solidMinArea, promises); - sliceFillAngle = (sliceFillAngle + 90.0) % 360; - }, "fill solids"); - // very last layer (top) is set to finish solid rate - slices.last().finishSolids = true - if (promises) { - await tracker(promises, (i, t) => { - trackupdate(i / t, 0.4, 0.5); - }); - } - profileEnd(); - } - - // for "real" objects, fill the remaining voids with sparse fill - // sparse layers only present when non-vase mose and sparse % > 0 - if (!isSynth && !vaseMode) { - let lastType; - let promises = isConcurrent ? [] : undefined; - forSlices(0.5, promises ? 0.55 : 0.7, slice => { - let params = slice.params || process; - if (!params.sliceFillSparse) { - return; - } - let newType = params.sliceFillType; - doSparseLayerFill(slice, { - settings, - process, - device, - lineWidth, - spacing: fillOffset, - density: params.sliceFillSparse, - bounds: widget.getBoundingBox(), - height: sliceHeight, - type: newType, - cache: params._range !== true && lastType === newType, - promises - }); - lastType = newType; - }, "infill"); - if (promises) { - await tracker(promises, (i, t) => { - trackupdate(i / t, 0.55, 0.7); - }); - } - // back-fill slices marked for infill cloning - for (let slice of slices) { - if (slice._clone_sparse) { - let tops = slice.tops; - let down = slice.down.tops; - for (let i=0; i p.cloneZ(slice.z)); - } - } - } - } else if (isSynth && isSupport) { - // convert synth support widgets into support structure - let outline = process.sliceSupportOutline || false; - let promises = isConcurrent ? [] : undefined; - let resolve = []; - forSlices(0.5, promises ? 0.6 : 0.7, slice => { - let params = slice.params || process; - let density = params.sliceSupportDensity; - let supports = slice.topShells(); - slice.supports = supports; - slice.tops = undefined; // remove outline leave only supports - let polys = []; - if (density) { - if (!outline) { - POLY.expand(supports, lineWidth, slice.z, polys); - } else { - POLY.expand(supports, -lineWidth/4, slice.z, polys); - } - } - fillSupportPolys({ - promises, polys, lineWidth, density, z: slice.z, isBelt, - angle: process.sliceSupportFill - }); - resolve.push({ slice, polys }); - }, "infill"); - if (promises) { - await tracker(promises, (i, t) => { - trackupdate(i / t, 0.6, 0.7); - }); - } - for (let rec of resolve) { - let { slice, polys } = rec; - rec.supports = polys; - } - } - - // auto support generation - if (!isBelt && !isSynth && supportDensity && process.sliceSupportEnable) { - doShadow(slices); - profileStart("support"); - let promises = []; - forSlices(0.7, 0.75, slice => { - promises.push(doSupport(slice, process, widget.shadow, { })); - }, "support"); - await tracker(promises, (i, t) => { - trackupdate(i / t, 0.75, 0.8); - }); - profileEnd(); - } - - // fill all supports (auto and manual) - // if (!isBelt && supportDensity) { - if (supportDensity) { - profileStart("support-fill"); - let promises = false && isConcurrent ? [] : undefined; - forSlices(0.8, promises ? 0.88 : 0.9, slice => { - let params = slice.params || process; - let density = params.sliceSupportDensity; - doSupportFill({ - promises, slice, lineWidth, density, - minArea: process.sliceSupportArea, isBelt, - angle: process.sliceSupportFill, - outline: process.sliceSupportOutline !== false - }); - }, "support"); - if (promises) { - await tracker(promises, (i, t) => { - trackupdate(i / t, 0.88, 0.9); - }); - } - profileEnd(); - } - - if (isBrick) { - let indices = slices.map(s => s.index); - let first = indices[1]; - let last = indices[indices.length - 2]; - let nu = []; - for (let slice of slices) { - if (slice.index < first || slice.index > last) { - continue; - } - let nuSlice = slice.clone(); - nuSlice.z -= slice.height / 2; - if (slice.index === first) { - nuSlice.z = slice.z - slice.height / 4; - nuSlice.height = slice.height / 2; - } else { - nuSlice.height = slice.height; - } - nu.push(nuSlice); - let ti = 0; - for (let top of slice.tops || []) { - let nuTop = nuSlice.tops[ti++]; - nuTop.shells = []; - top.shells = top.shells.filter((s,i) => { - if (i % 2 === 0) { - return true; - } else { - nuTop.shells.push(s); - return false; - } - }); - } - if (slice.index === last) { - let cap = nuSlice.clone(); - cap.z += (slice.height * 0.75); - cap.height = (slice.height / 2); - nu.push(cap); - cap.tops?.forEach((top, i) => { - top.shells = nuSlice.tops[i].shells.clone(); - }); - } - } - slices.appendAll(nu); - slices.sort((a,b) => a.z - b.z); - slices.forEach((s,i) => s.index = i); - } - - // render if not explicitly disabled - if (render) { - forSlices(0.9, 1.0, slice => { - let params = slice.params || process; - doRender(slice, isSynth, params, { - dark: controller.dark, - devel: controller.devel - }); - }, "render"); - } - - if (isBelt) { - let bounds = newBounds(); - let slice = slices[0]; - if (slice.tops) { - for (let top of slice.tops) { - bounds.merge(top.poly.bounds); - } - } else if (slice.supports) { - for (let poly of slice.supports) { - bounds.merge(poly); - } - } - widget.belt.miny = -bounds.miny; - widget.belt.midy = (bounds.miny + bounds.maxy) / 2; - } - } - -} - -function connect_lines(lines, maxd = Infinity) { - const newlines = []; - let op2; - let eo = 0; - for (let i=0; i { - if (isThin) output - .setLayer('part', Color.part) - .addPolys([top.poly]); - - if (top.shells?.length) output - .setLayer(isSynth ? "support" : "shells", isSynth ? Color.support : Color.shell) - .addPolys(top.shells || [], vopt({ offset, height, clean: true })); - - if (isThin && top.thin_wall?.length) output - .setLayer("walls", Color.thin) - .addPolys(top.thin_wall.map(a => a.map(p => newPolygon().centerCircle(p, p.r, 12).setZ(slice.z))).flat(), vopt({ offset, height })); - - if (!isThin && top.thin_wall?.length) output - .setLayer("walls", Color.thin) - .addPolys(top.thin_wall.map(a => - a.length === 1 ? - newPolygon().centerCircle(a[0], a[0].r/2, 12) : - newPolygon(a.map(p => newPoint(p.x, p.y))) - .setZ(slice.z) - .closeIf(height) - ), vopt({ offset, height })); - - if (top.fill_lines?.length) output - .setLayer("solid fill", isSynth ? Color.support : Color.fill) - .addLines(top.fill_lines || [], vopt({ offset: offset * solidWidth, height, z:slice.z })); - - if (!(slice.belt?.anchor)) output - .setLayer("sparse fill", Color.infill) - .addPolys(top.fill_sparse || [], vopt({ offset, height, outline: true, trace:true })) - - if (slice.belt?.anchor) output - .setLayer("anchor", Color.anchor) - .addPolys(top.fill_sparse || [], vopt({ offset, height, outline: true, trace:true })) - - if (top.thin_fill?.length) output - .setLayer("thin fill", Color.thin) - .addLines(top.thin_fill, vopt({ offset, height })); - - if (top.gaps?.length && devel) output - .setLayer("gaps", Color.gaps) - .addPolys(top.gaps, vopt({ offset, height, thin: true })); - - if (isThin && devel && top.fill_off?.length) { - slice.output() - .setLayer('fill inset', Color.inset) - .addPolys(top.fill_off); - // .setLayer('last', { face: 0, line: 0x008888, check: 0x008888 }) - // .addPolys(top.last); - } - }); - - if (isThin && devel) { - if (slice.solids && slice.solids.length) output - .setLayer("solids", { face: 0xbbbb00, check: 0xbbbb00 }) - .addAreas(slice.solids); - - if (slice.bridges && slice.bridges.length) output - .setLayer("bridges", { face: 0x00cccc, line: 0x00cccc, check: 0x00cccc }) - .addAreas(slice.bridges); - - if (slice.flats && slice.flats.length) output - .setLayer("flats", { face: 0xaa00aa, line: 0xaa00aa, check: 0xaa00aa }) - .addAreas(slice.flats); - } - - if (slice.supports && params.sliceSupportOutline) output - .setLayer("support", Color.support) - .addPolys(slice.supports, vopt({ offset, height })); - - if (slice.supports) slice.supports.forEach(poly => { - if (poly.fill) output - .setLayer("support", Color.support) - .addLines(poly.fill, vopt({ offset, height })); - }); - - if (slice.xray) { - const color = [ 0xff0000, 0x00aa00, 0x0000ff, 0xaaaa00, 0xff00ff, 0x0 ]; - if (slice.lines) { - slice.lines.forEach((line, i) => { - const group = i % 5; - slice.output().setLayer(`l${group}`, color[group]).addLine(line.p1, line.p2); - }); - } - if (slice.groups) - POLY.nest(slice.groups).forEach((poly, i) => { - const group = i % 5; - slice.addTop(poly); - // slice.output().setLayer(`g${i}`, 0x888888).addPoly(poly); - slice.output().setLayer(`g${i}`, color[group]).addPoly(poly); - }); - } - - // console.log(slice.index, slice.render.stats); -} - -/** - * Compute offset shell polygons. For FDM, the first offset is usually half - * of the nozzle width. Each subsequent offset is a full nozzle width. User - * parameters control tweaks to these numbers to allow for better shell bonding. - * The last shell generated is a "fillOffset" shell. Fill lines are clipped to - * this polygon. Adjusting fillOffset controls bonding of infill to the shells. - * - * Most of this is done in slicePost() in FDM mode. now this is used by SLA, Laser - * - * @param {number} count - * @param {number} offsetN - * @param {number} fillOffset - * @param {Obejct} options - */ -export function doShells(slice, count, offset1, offsetN, fillOffset, opt = {}) { - for (let top of slice.tops) { - doTopShells(slice.z, top, count, offset1, offsetN, fillOffset, opt); - } -} - -/** - * Create an entirely solid layer by filling all top polygons - * with an alternating pattern. - * - * @param {number} linewidth - * @param {number} angle - * @param {number} density - */ - function doSolidLayerFill(slice, spacing, angle) { - if (slice.xray) { - return; - } - - if (slice.tops.length === 0 || typeof(angle) != 'number') { - slice.isSolidLayer = false; - return; - } - - slice.tops.forEach(function(top) { - if (!top.fill_off) return; // missing for inner brick layers - let lines = POLY.fillArea(top.fill_off, angle, spacing, null); - top.fill_lines.appendAll(lines); - }); - - slice.isSolidLayer = true; -}; - -/** - * Take output from pluggable sparse infill algorithm and clip to - * the bounds of the top polygons and their inner solid areas. - */ -function doSparseLayerFill(slice, options = {}) { - if (slice.xray || options.type === 'none') { - return; - } - - let process = options.process, - spacing = options.spacing, // spacing space between fill lines - density = options.density, // density of infill 0.0 - 1.0 - bounds = options.bounds, // bounding box of widget - height = options.height, // z layer height - cache = !(options.cache === false), - type = options.type || 'hex'; - - if (slice.tops.length === 0 || density === 0.0 || slice.isSolidLayer || slice.index < 0) { - slice.isSparseFill = false; - return; - } - - let tops = slice.tops, - down = slice.down, - clib = self.ClipperLib, - ctyp = clib.ClipType, - ptyp = clib.PolyType, - cfil = clib.PolyFillType, - clip = new clib.Clipper(), - ctre = new clib.PolyTree(), - poly, - polys = [], - lines = [], - line = [], - solids = [], - // callback passed to pluggable infill algorithm - target = { - // slice and slice property access - slice: function() { return slice }, - zIndex: function() { return slice.index }, - zValue: function() { return slice.z }, - // various option map access - options: function() { return options }, - lineWidth: function() { return options.lineWidth }, - bounds: function() { return bounds }, - zHeight: function() { return height }, - offset: function() { return spacing }, - density: function() { return density }, - repeat: function() { return process.sliceFillRepeat }, - // output functions - emit: function(x,y) { - if (isNaN(x)) { - solids.push(x); - } else { - line.push(newPoint(x, y, slice.z)); - slice.isSparseFill = true; - } - }, - newline: function() { - if (line.length > 0) { - lines.push(line); - line = []; - } - } - }; - - // use specified fill type - if (type && fill[type]) { - fill[type](target); - } else { - console.log({missing_infill: type}); - return; - } - - // force emit of last line - target.newline(); - - // prepare top infill structure - for (let top of tops) { - top.fill_sparse = top.fill_sparse || []; - polys.appendAll(top.fill_off); - polys.appendAll(top.solids); - } - - // update fill fingerprint for this slice - slice._fill_finger = POLY.fingerprint(polys); - - let skippable = cache && fill_fixed[type] ? true : false; - let miss = false; - // if the layer below has the same fingerprint, - // we may be able to clone the infill instead of regenerating it - if (skippable && slice.fingerprintSame(down)) { - // the fill fingerprint can slightly different because of solid projections - if (down._fill_finger && POLY.fingerprintCompare(slice._fill_finger, down._fill_finger)) { - for (let i=0; i { - top.fill_lines.push(p1, p2); - }); - } - } - if (fillable.length) { - let lines = POLY.fillArea(fillable, angl, options.lineWidth); - top.fill_lines.appendAll(lines); - } - } - } - } - - // if only solids were added and no lines to clip - if (!sparse_clip) { - return; - } - - if (options.promises) { - options.promises.push(self.kiri_worker.minions.clip(slice, polys, lines)); - return; - } - - lines = lines.map(a => a.map(p => p.toClipper())); - clip.AddPaths(lines, ptyp.ptSubject, false); - clip.AddPaths(POLY.toClipper(polys), ptyp.ptClip, true); - - if (clip.Execute(ctyp.ctIntersection, ctre, cfil.pftNonZero, cfil.pftEvenOdd)) { - for (let node of ctre.m_AllPolys) { - poly = POLY.fromClipperNode(node, slice.z); - for (let top of tops) { - // use only polygons inside this top - if (poly.isInside(top.poly)) { - top.fill_sparse.push(poly); - } - } - } - } -}; - -/** - * Find difference between fill inset poly on two adjacent layers. - * Used to calculate bridges, flats and then solid projections. - * 'expand' is used for top offsets in SLA mode - */ -export function doDiff(slice, options = {}) { - const { sla, fakedown, grow, min } = options; - if ((slice.index <= 0 && !fakedown) || slice.xray) { - return; - } - const top = slice, - down = slice.down || (fakedown ? newSlice(-1) : null), - topInner = sla ? top.topPolys() : top.topInners(), - downInner = sla ? down.topPolys() : down.topInners(), - bridges = top.bridges = [], - flats = down.flats = []; - - // skip diffing layers that are identical - if (slice.fingerprintSame(down)) { - top.bridges = bridges; - down.flats = flats; - return; - } - - let newBridges = []; - let newFlats = []; - - POLY.subtract(topInner, downInner, newBridges, newFlats, slice.z, min, { - wasm: true - }); - - newBridges = newBridges.filter(p => p.areaDeep() >= min); - newFlats = newFlats.filter(p => p.areaDeep() >= min); - - if (grow > 0 && newBridges.length) { - newBridges = POLY.offset(newBridges, grow); - } - if (grow > 0 && newFlats.length) { - newFlats = POLY.offset(newFlats, grow); - } - - bridges.appendAll(newBridges); - flats.appendAll(newFlats); -}; - -/** - * - * - * @param {Polygon[]} polys - */ -function addSolidFills(slice, polys) { - if (slice.solids) { - slice.solids.appendAll(polys); - } else if (polys && polys.length) { - console.log({no_solids_in: slice, for: polys}) - } -}; - -/** - * project bottom flats down - */ -export function projectFlats(slice, count, expand) { - if (!slice.down || !slice.flats) return; - // these flats are marked for finishing print speed - if (slice.flats?.length) { - slice.finishSolids = true; - slice.isFlatsLayer = true; - const flats = expand ? POLY.expand(slice.flats, expand) : slice.flats; - projectSolid(slice, flats, count, false, true); - } -}; - -/** - * project top bridges up - */ -export function projectBridges(slice, count) { - if (!slice.up || !slice.bridges) return; - // these flats are marked for finishing print speed - if (slice.bridges?.length) { - slice.finishSolids = true; - slice.isBridgeLayer = true; - } - projectSolid(slice, slice.bridges, count, true, true); -}; - -/** - * fill projected areas and store line data - * @return {boolean} true if filled, false if not - */ -function doSolidsFill(slice, spacing, angle, minArea, fillQ) { - let minarea = minArea || 1, - tops = slice.tops, - solids = slice.solids; - - if (!(tops && solids)) { - return; - } - - if (slice.isSolidLayer || slice.xray) { - return; - } - - let unioned = POLY.union(solids, undefined, true, { wasm: true }).flat(), - isSLA = (spacing === undefined && angle === undefined); - - if (solids.length === 0) return false; - if (unioned.length === 0) return false; - - let trims = [], - inner = isSLA ? slice.topPolys() : slice.topFillOff(); - - // trim each solid to the inner bounds - for (let p of unioned) { - p.setZ(slice.z); - for (let i of inner) { - let masks = p.mask(i); - if (masks && masks.length > 0) { - trims.appendAll(masks); - } - } - } - - // clear old solids and make array for new - tops.forEach(top => { top.solids = [] }); - - // replace solids with merged and trimmed solids - slice.solids = solids = trims; - - // parent each solid polygon inside the smallest bounding top - let make_solid_layer = false; - let tops_area = tops.length ? tops.map(top => top.poly.areaDeep()).reduce((a,i) => a+i) : 0; - for (let solid of solids) { - for (let top of tops) { - let stop = []; - if (top.poly.overlaps(solid)) { - if (!solid.parent || solid.parent.area() > top.poly.area()) { - if (solid.areaDeep() < minarea) { - // console.log({i:slice.index,cull_solid:solid,area:solid.areaDeep()}); - continue; - } - solid.parent = top.poly; - top.solids.push(solid); - stop.push(solid); - } - } - // problematic for organic shapes with lots of big and small tops - // the small tops tend to trigger entire layer fills. for now just - // trip full solid layer if a single top area diff > 50% - if (false && stop.length) { - let top_area = top.poly.areaDeep(); - let stop_area = stop.map(p => p.areaDeep()).reduce((a,v) => a + v); - // if the solid area > 50% of the top area, make entire layer solid - if (stop_area / tops_area > 0.5) { - make_solid_layer = true; - } - } - } - } - // if 50% of top is filled with solids, trigger layer conversion to solid - // in future, this should be limited to a specific top, not entire layer - if (make_solid_layer) { - for (let top of tops) { - top.solids = []; - } - doSolidLayerFill(slice, spacing, angle); - return; - } - - // for SLA to bypass line infill - if (isSLA) { - return true; - } - - // create empty filled line array for each top - for (let top of tops) { - // synth belt anchor tops don't want fill - if (!top.fill_lines) { - continue; - } - const tofill = []; - const angfill = []; - const newfill = top.fill_lines = []; - // determine fill orientation from top - for (let solid of solids) { - if (solid.parent === top.poly) { - if (solid.fillang) { - angfill.push(solid); - } else { - tofill.push(solid); - } - } - } - if (tofill.length > 0) { - doFillArea(fillQ, tofill, angle, spacing, newfill); - // top.fill_lines_norm = {angle:angle,spacing:spacing}; - } - if (angfill.length > 0) { - top.fill_lines_ang = {spacing:spacing,list:[],poly:[]}; - for (let af of angfill) { - doFillArea(fillQ, [af], af.fillang.angle + 45, spacing, newfill); - // top.fill_lines_ang.list.push(af.fillang.angle + 45); - // top.fill_lines_ang.poly.push(af.clone()); - } - } - } -} - -function doFillArea(fillQ, polys, angle, spacing, output, minLen, maxLen) { - if (fillQ) { - fillQ.push(self.kiri_worker.minions.fill(polys, angle, spacing, output, minLen, maxLen)); - } else { - POLY.fillArea(polys, angle, spacing, output, minLen, maxLen); - } -} - -/** - * calculate external overhangs requiring support - */ -async function doSupport(slice, proc, shadow, opt = {}) { - let { minions } = self.kiri_worker, - maxBridge = proc.sliceSupportSpan || 5, - minArea = proc.supportMinArea || 0.1, - pillarSize = proc.sliceSupportSize, - offset = proc.sliceSupportOffset || 0, - gap = proc.sliceSupportGap, - size = (pillarSize || 1), - tops = slice.topPolys(), - trimTo = tops; - - let traces = POLY.flatten(slice.topShells().clone(true)), - fill = slice.topFill(), - points = [], - down = slice.down, - down_tops = down ? down.topPolys() : null, - down_traces = down ? POLY.flatten(down.topShells().clone(true)) : null; - - if (opt.exp && down_tops) { - let points = down_tops.map(p => p.deepLength).reduce((a,v)=>a+v); - if (points > 200) { - // use de-rez'd top shadow instead - down_tops = down.topSimples(); - // de-rez trace polys because it's not that important for supports - down_traces = down_traces.map(p => p.clean(true, undefined, config.clipper / 10)); - } - } - - // DEBUG code - let SDBG = false; - let cks = SDBG ? [] : undefined; - let pip = SDBG ? [] : undefined; - let pcl = SDBG ? [] : undefined; - - // check if point is supported by layer below - function checkPointSupport(point) { - if (SDBG) cks.push(point); // DEBUG - // skip points close to other support points - for (let i=0; i= maxBridge) { - let slope = p1.slopeTo(p2).factor(1/dist), - segs = Math.floor(dist / maxBridge) + 1, - seglen = dist / segs; - while (i < segs) { - checkPointSupport(p1.projectOnSlope(slope, i++ * seglen)); - } - } - if (poly) checkPointSupport(p2); - } - - let supports = []; - - // generate support polys from unsupported points - if (slice.down) (function() { - // check trace line support needs - traces.forEach(function(trace) { - trace.forEachSegment(function(p1, p2) { checkLineSupport(p1, p2, true) }); - }); - - // add offset solids to supports (or fill depending) - fill.forEachPair(function(p1,p2) { checkLineSupport(p1, p2, false) }); - - // skip the rest if no points or supports - if (!(points.length || supports.length)) return; - - let pillars = []; - - // for each point, create a bounding rectangle - points.forEach(function(point) { - pillars.push(newPolygon().centerRectangle(point, size/2, size/2)); - }); - - supports.appendAll(POLY.union(pillars, null, true, { wasm: false })); - // merge pillars and replace with convex hull of outer points (aka smoothing) - pillars = POLY.union(pillars, null, true, { wasm: false }).forEach(function(pillar) { - supports.push(newPolygon().createConvexHull(pillar.points)); - }); - })(); - - // DEBUG code - if (SDBG && down_traces) slice.output() - .setLayer('cks', { line: 0xee5533, check: 0xee5533 }) - .addPolys(cks.map(p => newPolygon().centerRectangle(p, 0.25, 0.25))) - .setLayer('pip', { line: 0xdd4422, check: 0xdd4422 }) - .addPolys(pip.map(p => newPolygon().centerRectangle(p, 0.4, 0.4))) - .setLayer('pcl', { line: 0xcc3311, check: 0xcc3311 }) - .addPolys(pcl.map(p => newPolygon().centerRectangle(p, 0.3, 0.3))) - .setLayer('pts', { line: 0xdd33dd, check: 0xdd33dd }) - .addPolys(points.map(p => newPolygon().centerRectangle(p, 0.8, 0.8))) - .setLayer('dtr', { line: 0x0, check: 0x0 }) - .addPolys(POLY.setZ(down_traces.clone(true),slice.z)); - ; - - if (supports.length === 0) { - return; - } - - // then union supports - if (supports.length > 10) { - supports = await minions.union(supports); - } else { - supports = POLY.union(supports, null, true, { wasm: false }); - } - - // clip to top polys - supports = POLY.trimTo(supports, shadow); - - let depth = 0; - while (down && supports.length > 0) { - down.supports = down.supports || []; - - let trimmed = [], culled = []; - - // culled = supports; - // clip supports to shell offsets - POLY.subtract(supports, down.topSimples(), trimmed, null, slice.z, minArea, { wasm: false }); - - // set depth hint on support polys for infill density - trimmed.forEach(function(trim) { - if (trim.area() < minArea) return; - culled.push(trim.setZ(down.z)); - }); - - // exit when no more support polys exist - if (culled.length === 0) break; - - // new bridge polys for next pass (skip first layer below) - if (depth >= gap) { - down.supports.appendAll(culled); - } - - supports = culled; - down = down.down; - depth++; - } - -} - -function doSupportFill(args) { - const { promises, slice, lineWidth, density, minArea, isBelt, angle, outline } = args; - let supports = slice.supports, - nsB = [], - nsC = [], - min = minArea || 0.1; - - if (!supports) return; - - // union supports - supports = POLY.setZ(POLY.union(supports, undefined, true, { wasm: false }), slice.z); - - // clip supports to slice clip offset (or shell if none) - POLY.subtract(supports, slice.clips, nsB, null, slice.z, min, { wasm: false }); - supports = nsB; - - // also trim to lower offsets, if they exist - if (slice.down && slice.down.clips) { - POLY.subtract(nsB, slice.down.clips, nsC, null, slice.z, min, { wasm: false }); - supports = nsC; - } - - if (supports) { - fillSupportPolys({ - promises, polys: supports, lineWidth, density, z: slice.z, isBelt, angle, outline - }); - } - - // re-assign new supports back to slice - slice.supportOutline = supports; - slice.supports = supports; -}; - -function fillSupportPolys(args) { - const { promises, polys, lineWidth, density, z, isBelt, angle, outline } = args; - // calculate fill density - let spacing = lineWidth * (1 / density); - polys.forEach(function (poly) { - // calculate angle based on width/height ratio - let auto = isBelt || (poly.bounds.width() / poly.bounds.height() > 1) ? 1090 : 1000; - // inset support poly for fill lines 33% of nozzle width - let inset = POLY.offset([poly], -lineWidth/3, {flat: true, z, wasm: true}); - // do the fill - if (inset && inset.length > 0) { - doFillArea(promises, inset, angle || auto, spacing, poly.fill = []); - if (!outline && poly.fill.length) { - poly.fill = connect_lines(poly.fill, spacing * 2); - } - } - return true; - }); -} - -/** - * - * @param {Slice} slice - * @param {Polygon[]} polys - * @param {number} count - * @param {boolean} up - * @param {boolean} first - * @returns {*} - */ -function projectSolid(slice, polys, count, up, first) { - if (slice && !up && count === 1) { - slice.isBridgeLayer = true; - } - if (!slice || count <= 0) { - return; - } - let clones = polys.clone(true); - if (first) { - clones.forEach(function(p) { - p.hintFillAngle(); - }); - } - addSolidFills(slice, clones); - if (count > 0) { - if (up) projectSolid(slice.up, polys, count-1, true, false); - else projectSolid(slice.down, polys, count-1, false, false); - } -} - -export function supports(settings, widget) { - let isBelt = settings.device.bedBelt; - let process = settings.process; - let size = process.sliceSupportSize; - let s9 = size / 9; - let s4 = size / 4; - let s2 = size * 0.45; - let min = 0.01; - let geo = new THREE.BufferGeometry(); - geo.setAttribute('position', new THREE.BufferAttribute(widget.vertices, 3)); - let rad = (Math.PI / 180); - let deg = (180 / Math.PI); - let angle = rad * settings.process.sliceSupportAngle; - let thresh = -Math.sin(angle); - let dir = new THREE.Vector3(0,0,-1) - let add = []; - let mat = new THREE.MeshBasicMaterial(); - let mesh = new THREE.Mesh(geo, mat); - let platform = new THREE.Mesh( - new THREE.PlaneGeometry(10000,10000,1), mat - ); - const now = Date.now(); - // test point - function tp(point) { - if (point.added) { - return; - } - // omit pillars close to existing pillars - for (let added of add) { - let p2 = new THREE.Vector2(point.x, point.y); - let pm = new THREE.Vector2(added.mid.x, added.mid.y); - if (Math.abs(point.z - added.from.z) < s2 && p2.distanceTo(pm) < s4) { - return; - } - } - let ray = new THREE.Raycaster(point, dir); - let int = ray.intersectObjects([ mesh, platform ], false); - if (int && int.length && int[0].distance > 0.5) { - let mid = new THREE.Vector3().add(point).add(int[0].point).divideScalar(2); - add.push({from: point, to: int[0].point, mid}); - point.added = true; - } - } - function tf(a, b, c) { - let dab = a.distanceTo(b); - let dbc = b.distanceTo(c); - let dca = c.distanceTo(a); - let max = Math.max(dab, dbc, dca); - if (max < size) { - let min = Math.min(dab, dbc, dca); - if (min < s9 && Math.random() < 0.5) { - return; - } - // test midpoint of tri face - return tp(new THREE.Vector3().add(a).add(b).add(c).divideScalar(3)); - } - if (dab === max) { - let mp = new THREE.Vector3().add(a).add(b).divideScalar(2); - tf(mp, b, c); - tf(a, mp, c); - } else if (dbc === max) { - let mp = new THREE.Vector3().add(b).add(c).divideScalar(2); - tf(a, mp, c); - tf(a, b, mp); - } else { - let mp = new THREE.Vector3().add(c).add(a).divideScalar(2); - tf(a, b, mp); - tf(mp, b, c); - } - } - - let filter = isBelt ? (norm) => { - return norm.z <= thresh && norm.y < 0; - } : (norm) => { - return norm.z < thresh; - }; - let { position } = geo.attributes; - let { itemSize, count, array } = position; - for (let i = 0; i 0; -}; - -class Vector3Cache { - constructor() { - this.cache = {}; - } - - get(x, y, z) { - let key = [x.round(4),y.round(4),z.round(4)].join(','); - let val = this.cache[key]; - if (!val) { - val = new THREE.Vector3(x, y, z); - this.cache[key] = val; - } - return val; - } -} - -class Coplanars { - constructor() { - this.cache = {}; - } - - put(a, b, c, norm) { - let key = norm.round(7).toString(); - let arr = this.cache[key]; - if (!arr) { - arr = []; - this.cache[key] = arr; - } - arr.push([a,b,c]); - } - - group(union) { - let out = {}; - for (let norm in this.cache) { - let arr = this.cache[norm]; - let groups = []; - for (let face of arr) { - let match = undefined; - // see if face matches vertices in any group - outer: for (let group of groups) { - for (let el of group) { - if ( - el.indexOf(face[0]) >= 0 || - el.indexOf(face[1]) >= 0 || - el.indexOf(face[2]) >= 0 - ) { - match = group; - break outer; - } - } - } - if (match) { - match.push(face); - } else { - groups.push([face]); - } - } - if (union) { - // convert groups of faces to contiguous polygon groups - groups = groups.map(group => { - let parr = group.map(arr => { - return newPolygon() - .add(arr[0].x, arr[0].y, arr[0].z) - .add(arr[1].x, arr[1].y, arr[1].z) - .add(arr[2].x, arr[2].y, arr[2].z); - }); - let union = parr.length === 1 ? parr : - POLY.union(parr, 0, true, {wasm:false}); - union.merged = parr.length; - union.face = group[0]; - return union; - }); - } - out[norm] = groups; - } - // console.log(out); - return out; - } -} diff --git a/src/kiri/mode/fdm/work/belt.js b/src/kiri/mode/fdm/work/belt.js new file mode 100644 index 00000000..b3979a6a --- /dev/null +++ b/src/kiri/mode/fdm/work/belt.js @@ -0,0 +1,316 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +/** + * Belt printer specific functions for FDM slicing + * Handles anchor generation, text embossing, and belt-specific bounds + */ + +import { newPolygon } from '../../../../geo/polygon.js'; +import { newBounds } from '../../../../geo/bounds.js'; +import { newSlice } from '../../../core/slice.js'; +import { getRangeParameters } from '../core/params.js'; + +// Import constants from slice.js +// These would ideally be shared, but for now we'll define locally +const BELT = { + PRECISION_ROUNDING: 3, + PEEK_LAYERS: 5, + ANCHOR_BUMP_SPACING_MULT: 2, + MIN_WIDTH_FOR_HATCH: 10, + HATCH_SPACING: 3, + POOCH_Y_RANGE_MIN: 0, + POOCH_Y_RANGE_MAX: 3, + POOCH_FLATNESS_TOLERANCE: 0.01, + POOCH_WIDTH_TOLERANCE: 1, + POOCH_DY_TOLERANCE: 0.1, + POOCH_DZ_TOLERANCE: 1, + POOCH_TEXT_SCALE_X: 1.2, + POOCH_TEXT_SCALE_Y: 1, + POOCH_FONT_SIZE: 24, + POOCH_PIXEL_THRESHOLD: 30, +}; + +/** + * Generate belt anchor for lead-in adhesion + * + * Creates a lead-in anchor section before the actual part begins printing. + * The anchor helps adhesion on belt printers by providing a larger contact area. + * Optionally adds "bumps" to the anchor for improved adhesion. + * + * @param {Object} options - Configuration options + * @param {Array} options.slices - Array of slices to process + * @param {Object} options.widget - Widget being sliced + * @param {Object} options.process - Process parameters + * @param {number} options.lineWidth - Extrusion width in mm + * @param {number} options.sliceHeight - Layer height in mm + * @param {number} options.sliceHeightBase - First layer height in mm + * @param {number} options.extruder - Extruder index + * @returns {void} Modifies slices array in place + */ +export function generateBeltAnchor({ slices, widget, process, lineWidth, sliceHeight, sliceHeightBase, extruder }) { + let { belt } = widget; + let { cosf, slope } = belt; + + // find adjusted zero point from slices + let smin = Infinity; + for (let slice of slices) { + let miny = Infinity; + for (let poly of slice.topPolys()) { + let y = poly.bounds.maxy; + let z = slice.z; + // at 45 degrees, 1mm in Z is 1mm in Y + let by = (slope * z) - y; + if (by < miny) miny = by; + if (by < smin) smin = by; + } + // mark slices with tops touching belt + slice.belt = { miny, touch: miny.round(BELT.PRECISION_ROUNDING) < sliceHeightBase }; + } + + // find max width of first N layers for brim additions + let start; + let minx = Infinity, maxx = -Infinity; + let peek = 0; + for (let slice of slices) { + if (slice.tops.length && peek++ < BELT.PEEK_LAYERS) { + for (let poly of slice.topPolys()) { + minx = Math.min(minx, poly.bounds.minx); + maxx = Math.max(maxx, poly.bounds.maxx); + } + } + // find first slice touching belt for start of anchor + if (!start && slice.belt.touch) { + start = slice; + } + } + + // ensure we start against a layer with shells + while (start && start.up && start.topShells().length === 0) { + start = start.up; + } + + // if a brim applies, add that width to anchor + let brim = getRangeParameters(process, 0).firstLayerBrim || 0; + if (brim) { + minx -= brim; + maxx += brim; + } + + // array of added top.fill_sparse arrays + let adds = []; + let step = sliceHeight; + let anchorlen = (process.beltAnchor || process.firstLayerBeltLead) * cosf; + + // offset used in support generation + belt.anchor = { + added: adds, + len: anchorlen + }; + + while (anchorlen && start && anchorlen >= sliceHeight) { + let addto = start.down; + if (!addto) { + addto = newSlice(start.z - step); + addto.extruder = extruder; + addto.belt = { }; + addto.height = start.height; + addto.up = start; + start.down = addto; + slices.splice(0,0,addto); + } else if (!addto.belt) { + console.log({addto_missing_belt: addto}); + addto.belt = {}; + } + addto.index = -1; + addto.belt.anchor = true; + // this allows the anchor to print bi-directionally + // by removing the forced start-point in print.js + addto.belt.touch = false; + let z = addto.z; + let y = (slope * z) - smin - (lineWidth / 2); + let splat = newPolygon().add(minx, y, z).add(maxx, y, z).setOpen(); + let snew = addto.addTop(splat).fill_sparse = [ splat ]; + adds.push(snew); + start = addto; + anchorlen -= (step * slope); + } + + // add anchor bump + let bump = process.firstLayerBeltBump; + if (bump) { + adds = adds.reverse().slice(1, adds.length - 1); + let count = 1; + for (let add of adds) { + let poly = add[0]; + let y = count++ * -start.height * BELT.ANCHOR_BUMP_SPACING_MULT; + if (-y > bump) { + count--; + } + let first = poly.first(); + // add up/over/down to anchor line (close = down) + // which completes the bump perimeter + poly.push(poly.last().add({x:0, y, z:0})); + poly.push(poly.first().add({x:0, y, z:0})); + poly.setClosed(); + if (count > 2 && maxx - minx > BELT.MIN_WIDTH_FOR_HATCH) { + // add vertical hatch lines inside bump shell + let mp = (maxx + minx) / 2; + let dx = (maxx - minx - 2); + dx = (Math.floor(dx / BELT.HATCH_SPACING) * BELT.HATCH_SPACING) / 2; + let fy = first.y; + let fz = first.z; + let n2 = lineWidth / 2; + for (let x = mp - dx; x <= mp + dx ; x += BELT.HATCH_SPACING) { + add.push(newPolygon().add(x, fy - n2, fz).add(x, fy + y + n2, fz).setOpen() ); + } + } + } + } +} + +/** + * Experimental: Emboss text on flat underside of belt-printed object + * + * Scans for flat underside surfaces and embosses specified text using + * canvas rendering and pixel detection. The text is converted to support + * patterns that create raised or recessed text on the bottom surface. + * + * @param {Object} options - Configuration options + * @param {Array} options.slices - Array of slices to process + * @param {Object} options.widget - Widget being sliced + * @param {Object} options.process - Process parameters + * @param {number} options.lineWidth - Extrusion width in mm + * @param {number} options.smin - Minimum belt Y offset + * @returns {void} Modifies slice.supports arrays + */ +export function embossBeltPooch({ slices, widget, process, lineWidth, smin }) { + if (!process.pooch || !self.OffscreenCanvas) { + return; + } + + const { length, width, height, text } = process.pooch; + const { slope } = widget.belt; + let firstZ, firstI, lastZ, lastI, minX = 0, maxX = 0, maxY = 0; + + // locate suitable flat spot + for (let slice of slices) { + let { belt } = slice; + if (!belt.touch) { + continue; + } + let index = slice.index; + let z = slice.z; + outer: for (let poly of slice.topPolys()) { + for (let i=0, p=poly.points, l=p.length; i BELT.POOCH_Y_RANGE_MIN && p0y < BELT.POOCH_Y_RANGE_MAX && + Math.abs(p0y - p1y) < BELT.POOCH_FLATNESS_TOLERANCE; + let x_ok = Math.abs(width - Math.abs(p1.x - p0.x)) < BELT.POOCH_WIDTH_TOLERANCE + if (y_ok && x_ok) { + if (i_ok) { + firstZ = firstZ || z; + firstI = firstI || index; + lastZ = z; + lastI = index; + minX = Math.min(p0.x, p1.x); + maxX = Math.max(p0.x, p1.x); + maxY = p0y; + break outer; + } else { + firstZ = lastI = 0; + } + } + } + } + } + + let dy = Math.abs(height - maxY * (1 / Math.sqrt(2))); + let dz = Math.abs(length - ((lastZ - firstZ) * Math.sqrt(2))); + if (dy < BELT.POOCH_DY_TOLERANCE && dz < BELT.POOCH_DZ_TOLERANCE) { + // console.log('FOUND', { firstI, lastI, minX, maxX, maxY }); + let span = lastI - firstI - 2; // x = down the belt + let tall = width * 2; + let can = new self.OffscreenCanvas(span, tall); + let ctx = can.getContext("2d"); + ctx.scale(BELT.POOCH_TEXT_SCALE_X, BELT.POOCH_TEXT_SCALE_Y); + ctx.font = `${BELT.POOCH_FONT_SIZE}px sans-serif`; + ctx.textBaseline = "bottom"; + ctx.fillText(text, 1, tall - 1); + let img = ctx.getImageData(0, 0, span, tall).data.buffer; + let rgb = new Uint32Array(img); + + for (let x=0; x=0; y--) { + let pix = rgb[y * span + x]; + pix = ( + ((pix >> 24) & 0xff) + + ((pix >> 16) & 0xff) + + ((pix >> 8) & 0xff) + ) / 3; + str += pix > BELT.POOCH_PIXEL_THRESHOLD ? '*' : '-'; + maxp = Math.max(maxp, pix); + if (pix > BELT.POOCH_PIXEL_THRESHOLD) { + if (start >= 0) { + end = tall - y; + } else { + start = tall - y; + } + } else { + if (start >= 0 && end > start) { + lines.push({ start, end }); + } + start = end = undefined; + } + } + console.log((x).toString().padStart(2,0),str,maxp | 0,lines); + if (lines.length) { + let slice = slices[firstI + x + 1]; + let supps = slice.supports = slice.supports || []; + let z = slice.z; + let y = z - smin - (lineWidth / 2); + for (let line of lines) { + supps.push(newPolygon() + .add(minX + line.start / 2, y, z) + .add(minX + line.end / 2, y, z) + .setOpen() + ); + } + } + } + } +} + +/** + * Finalize belt-specific bounds for widget tracking + * + * Calculates final Y-axis bounds for belt-printed parts, + * used for positioning and collision detection. + * + * @param {Object} options - Configuration options + * @param {Array} options.slices - Array of processed slices + * @param {Object} options.widget - Widget being sliced + * @returns {void} Modifies widget.belt properties + */ +export function finalizeBeltBounds({ slices, widget }) { + let bounds = newBounds(); + let slice = slices[0]; + if (slice.tops) { + for (let top of slice.tops) { + bounds.merge(top.poly.bounds); + } + } else if (slice.supports) { + for (let poly of slice.supports) { + bounds.merge(poly); + } + } + widget.belt.miny = -bounds.miny; + widget.belt.midy = (bounds.miny + bounds.maxy) / 2; +} diff --git a/src/kiri/mode/fdm/export.js b/src/kiri/mode/fdm/work/export.js similarity index 64% rename from src/kiri/mode/fdm/export.js rename to src/kiri/mode/fdm/work/export.js index 5c5c305b..4ba3cd24 100644 --- a/src/kiri/mode/fdm/export.js +++ b/src/kiri/mode/fdm/work/export.js @@ -1,9 +1,9 @@ /** Copyright Stewart Allen -- All Rights Reserved */ -import { base, util } from '../../../geo/base.js'; -import { getRangeParameters } from './driver.js'; -import { FDM } from './driver-be.js'; -import { version } from '../../../moto/license.js'; +import { base, util } from '../../../../geo/base.js'; +import { getRangeParameters } from '../core/params.js'; +import { FDM } from './init-work.js'; +import { version } from '../../../../moto/license.js'; const { config } = base; const debug = false; @@ -26,6 +26,7 @@ export function fdm_export(print, online, ondone, ondebug) { { extrudeAbs } = device, { exportThumb } = controller, { extrudeMM, extrudePerMM } = FDM, + { outputInvertX, outputInvertY } = process, timeFactor = (device.gcodeTime || 1) * 1.5, decimals = config.gcode_decimals || 4, zMoveMax = device.deviceZMax || 0, @@ -57,10 +58,6 @@ export function fdm_export(print, online, ondone, ondebug) { retSpeed = process.outputRetractSpeed * 60 || 1, // range retDwell = process.outputRetractDwell || 0, // range scarfZ = process.outputScarfLength ? true : false, - arcDist = isBelt || scarfZ ? 0 : (process.arcTolerance || 0), - arcRes = 20, - arcDev = 0.5, - arcMax = 40, originCenter = device.originCenter || bedRound, offset = originCenter ? { x: 0, @@ -119,11 +116,11 @@ export function fdm_export(print, online, ondone, ondebug) { bcos = Math.cos(Math.PI / 4), icos = 1 / bcos, inloops = 0, - arcQ = [], minz = { x: Infinity, y: Infinity, z: Infinity }, // lenghts of each filament (by nozzle) consumed segments = []; + // build tool use list for this job let tools_used = Object.keys(tools); for (let tool of tools_used) { subst[`tool_used_${tool}`] = true; @@ -159,7 +156,7 @@ export function fdm_export(print, online, ondone, ondebug) { // smallish band-aid. refactor above to remove redundancy function updateParams(layer, params) { - // params = getRangeParameters(process, layer); + if (!params) params = getRangeParameters(process, layer); zhop = params.zHopDistance || 0; // range retDist = params.outputRetractDist || 0; // range retSpeed = params.outputRetractSpeed * 60 || 1; // range @@ -305,7 +302,7 @@ export function fdm_export(print, online, ondone, ondebug) { for (let pk in process) { appendTok("; ", pk, " = ", process[pk]); } - append("; --- startup ---"); + if (pre < 3) append("; --- startup ---"); } // looking for ";; PREAMBLE " comment @@ -316,6 +313,7 @@ export function fdm_export(print, online, ondone, ondebug) { if (line.indexOf(";; PREAMBLE") === 0) { if (line === ';; PREAMBLE OFF') pre = 1; else if (line === ';; PREAMBLE END') pre = 2; + else if (line === ';; PREAMBLE POST') pre = 3; else gcpre.push(pre = 123); } else if (line.indexOf(";; AXISMAP ") === 0) { let axmap = JSON.parse(line.substring(11).trim()); @@ -351,6 +349,7 @@ export function fdm_export(print, online, ondone, ondebug) { if (line.indexOf('G92') === 0) { line.split(";")[0].split(' ').forEach(function (tok) { let val = parseFloat(tok.substring(1) || 0) || 0; + if (isNaN(val)) console.log(val, line); switch (tok[0]) { case 'X': pos.x = val; break; case 'Y': pos.y = val; break; @@ -359,15 +358,17 @@ export function fdm_export(print, online, ondone, ondebug) { } }); } - if (line.indexOf('E') > 0) { - line.split(";")[0].split(' ').forEach(function (tok) { + if (line.indexOf('E') > 0 && line[0] === 'G') { + line.split(";")[0].split(' ').forEach((tok) => { // use max E position from gcode-preamble if (tok[0] == 'E') { + let etok = parseFloat(tok.substring(1) || 0); + if (isNaN(etok)) console.log(line); if (extrudeAbs) { - outputLength = Math.max(outputLength, parseFloat(tok.substring(1)) || 0); + outputLength = Math.max(outputLength, etok); emitted = outputLength; } else { - emitted += parseFloat(tok.substring(1) || 0); + emitted += etok; } } }); @@ -410,7 +411,6 @@ export function fdm_export(print, online, ondone, ondebug) { if (!retracted) { return; } - drainQ(); // console.log({engage:zhop}); // when enabled, resume previous Z if (zhop && pos.z != zpos) moveTo({z:zpos}, seekMMM, "z-hop end"); @@ -426,26 +426,10 @@ export function fdm_export(print, online, ondone, ondebug) { time += (retDist / retSpeed) * 60 * 2; // retraction time } - let taxis = new THREE.Vector3( 1, 0, 0 ); - let tcent = new THREE.Vector2( 0, 0 ); - let angle = -Math.PI / 4; - let savePos = pos; - - function pushPos(newpos, rate, comment) { - savePos = Object.clone(pos); - moveTo({ - x: newpos.x + offset.x, - y: newpos.y + offset.y, - z: newpos.z + offset.z - }, rate, comment); - } - - function popPos(rate, comment) { - moveTo(savePos, rate, comment); - } - - function moveTo(newpos, rate, comment) { - let o = [!rate && !newpos.e ? 'G0' : 'G1']; + function moveTo(newpos, rate, comment, arc) { + let o = arc ? + [arc.clockwise ? 'G2' : 'G3'] : + [!rate && !newpos.e ? 'G0' : 'G1']; let emit = { x: false, y: false, z: false }; if (typeof newpos.x === 'number' && newpos.x !== pos.x) { pos.x = newpos.x; @@ -472,6 +456,11 @@ export function fdm_export(print, online, ondone, ondebug) { if (emit.x) o.append(axis.X).append(epos.x.toFixed(decimals)); if (emit.y) o.append(axis.Y).append(epos.y.toFixed(decimals)); if (emit.z) o.append(axis.Z).append(epos.z.toFixed(decimals)); + if (arc) { + let { x, y } = arc.center; + o.push(' I', x.toFixed(decimals)); + o.push(' J', y.toFixed(decimals)); + } if (debug) { if (emit.x) minz.x = Math.min(minz.x, epos.x); if (emit.y) minz.y = Math.min(minz.y, epos.y); @@ -607,36 +596,39 @@ export function fdm_export(print, online, ondone, ondebug) { // iterate through layer outputs for (pidx=0; pidx 1) { - let el = arcQ.length; - deem = arcQ[0].e !== rec.e; - depm = arcQ[0].emitPerMM !== rec.emitPerMM; - desp = arcQ[0].speedMMM !== rec.speedMMM; - } - // ondebug({arcQ}); - if (arcQ.length > 2) { - let el = arcQ.length; - let e1 = arcQ[0]; // first in arcQ - let e2 = arcQ[Math.floor(el/2)]; // mid in arcQ - let e3 = arcQ[el-1]; // last in arcQ - let e4 = arcQ[el-2]; // second last in arcQ - let e5 = arcQ[el-3]; // third last in arcQ - let cc = util.center2d(e1, e2, e3, 1); // find center - let lr = util.center2d(e3, e4, e5, 1); // find local radius - let dc = 0; - - let radFault = false; - if (lr) { - let angle = 2 * Math.asin(dist/(2*lr.r)); - radFault = Math.abs(angle) > Math.PI * 2 / arcRes; // enforce arcRes(olution) - // if (arcQ.center) { - // arcQ.rSum = arcQ.center.reduce( function (t, v) { return t + v.r }, 0 ); - // let avg = arcQ.rSum / arcQ.center.length; - // radFault = radFault || Math.abs(avg - lr.r) / avg > arcDev; // eliminate sharps and flats when local rad is out of arcDev(iation) - // } - } else { - radFault = true; - } - - if (cc) { - if ([cc.x,cc.y,cc.z,cc.r].hasNaN()) { - console.log({cc, e1, e2, e3}); - } - if (arcQ.length === 3) { - arcQ.center = [ cc ]; - arcQ.xSum = cc.x; - arcQ.ySum = cc.y; - arcQ.rSum = cc.r; - } else { - // check center point delta - arcQ.xSum = arcQ.center.reduce( function (t, v) { return t + v.x }, 0 ); - arcQ.ySum = arcQ.center.reduce( function (t, v) { return t + v.y }, 0 ); - arcQ.rSum = arcQ.center.reduce( function (t, v) { return t + v.r }, 0 ); - let dx = cc.x - arcQ.xSum / arcQ.center.length; - let dy = cc.y - arcQ.ySum / arcQ.center.length; - dc = Math.sqrt(dx * dx + dy * dy); - } - - // if new point is off the arc - if (deem || depm || desp || dc * arcQ.center.length / arcQ.rSum > arcDist || dist > cc.r || cc.r > arcMax || radFault || !arcValid()) { - // let debug = [deem, depm, desp, dc * arcQ.center.length / arcQ.rSum > arcDist, dist > cc.r, cc.r > arcMax, radFault]; - if (arcQ.length === 4) { - // not enough points for an arc, drop first point and recalc center - emitQrec(arcQ.shift()); - let tc = util.center2d(arcQ[0], arcQ[1], arcQ[2], 1); - // the new center is invalid as well. drop the first point - if (!tc) { - emitQrec(arcQ.shift()); - } else { - arcQ.center = [ tc ]; - let angle = 2 * Math.asin(arcQ[1].dist/(2*tc.r)); - if (Math.abs(angle) > Math.PI * 2 / arcRes) { // enforce arcRes on initial angle - emitQrec(arcQ.shift()); - } - } - } else { - // enough to consider an arc, emit and start new arc - let defer = arcQ.pop(); - drainQ(); - // re-add point that was off the last arc - arcQ.push(defer); - } - } else { - // new point is on the arc - arcQ.center.push(cc); - } - } else { - // drainQ on invalid center - drainQ(); - } - } + if (lastp && emit) { + // emitMM = emitPerMM * emit * dist; + emitMM = extrudeMM(dist, emitPerMM, emit); + if (scarfZ) { + moveTo({x, y, z:z + path.height/2, e:emitMM}, speedMMM); } else { - // emitMM = emitPerMM * out.emit * dist; - emitMM = extrudeMM(dist, emitPerMM, out.emit); - if (scarfZ) { - moveTo({x, y, z:z + path.height/2, e:emitMM}, speedMMM); - } else { - moveTo({x, y, e:emitMM}, speedMMM); - } - emitted += emitMM; + moveTo({x, y, e:emitMM}, speedMMM); } + emitted += emitMM; } else { - drainQ(); if (scarfZ) { moveTo({x, y, z:z + path.height/2}, speedMMM); } else { @@ -807,7 +724,6 @@ export function fdm_export(print, online, ondone, ondebug) { // retract filament if point retract flag set if (out.retract) { - drainQ(); retract(zhop); } @@ -815,10 +731,8 @@ export function fdm_export(print, online, ondone, ondebug) { if (dist && speedMMM) { time += (dist / speedMMM) * 60 * timeFactor; } - // if (isNaN(time)) { - // console.log({ layer, path, dist, speedMMM, timeFactor }); - // throw "NaN"; - // } + + // accumulate distance traveled and update macro var distance += dist; subst.progress = progress = Math.round((distance / totaldistance) * 100); @@ -828,8 +742,8 @@ export function fdm_export(print, online, ondone, ondebug) { lastProgress = progress; } - lastp = out.point; - laste = out.emit; + lastp = point; + laste = emit; } layer++; if (cwidget) { @@ -842,139 +756,11 @@ export function fdm_export(print, online, ondone, ondebug) { while (endloop-- > 0) { append(`M808`); } - drainQ(); print.total_time -= path.print_time; subst.remain_time = Math.ceil(print.total_time / 60); } - function emitQrec(rec) { - let {e, x, y, dist, emitPerMM, speedMMM} = rec; - emitMM = extrudeMM(dist, emitPerMM, e); - moveTo({x:x, y:y, e:emitMM}, speedMMM); - emitted += emitMM; - } - - function drainQ() { - if (!arcDist) { - return; - } - if (arcQ.length > 4) { - // ondebug({arcQ}); - let vec1 = new THREE.Vector2(arcQ[1].x - arcQ[0].x, arcQ[1].y - arcQ[0].y); - let vec2 = new THREE.Vector2(arcQ.center[0].x - arcQ[0].x, arcQ.center[0].y - arcQ[0].y); - let gc = vec1.cross(vec2) < 0 ? 'G2' : 'G3'; - let from = arcQ[0]; - let to = arcQ.peek(); - arcQ.xSum = arcQ.center.reduce( function (t, v) { return t + v.x }, 0 ); - arcQ.ySum = arcQ.center.reduce( function (t, v) { return t + v.y }, 0 ); - arcQ.rSum = arcQ.center.reduce( function (t, v) { return t + v.r }, 0 ); - let cl = arcQ.center.length; - let cc; - - let angle = util.thetaDiff( - Math.atan2((from.y - arcQ.ySum / cl), (from.x - arcQ.xSum / cl)), - Math.atan2((to.y - arcQ.ySum / cl), (to.x - arcQ.xSum / cl)), - gc === "G2" - ); - - if (Math.abs(angle) <= 3 * Math.PI / 4) { - cc = util.center2pr(from, to, arcQ.rSum / cl, gc === "G3"); - } - - if (!cc) { - cc = {x:arcQ.xSum/cl, y:arcQ.ySum/cl, z:arcQ[0].z, r:arcQ.rSum/cl}; - } - - // first arc point - emitQrec(from); - // console.log(arcQ.slice(), arcQ.center); - // console.log({first: from, last: arcQ.peek(), center: cc}); - // rest of arc to final point - let dist = arcQ.slice(1).map(v => v.dist).reduce((a,v) => a+v); - let emit = from.e;//arcQ.slice(1).map(v => v.e).reduce((a,v) => a+v); - emit = (from.emitPerMM * emit * dist); - outputLength += emit; - emitted += emit; - if (extrudeAbs) { - emit = outputLength; - } - // XYR form - // let pre = `${gc} X${to.x.toFixed(decimals)} Y${to.y.toFixed(decimals)} R${cc.r.toFixed(decimals)} E${emit.toFixed(decimals)}`; - // XYIJ form - let pre = `${gc} X${to.x.toFixed(decimals)} Y${to.y.toFixed(decimals)} I${(cc.x - pos.x).toFixed(decimals)} J${(cc.y - pos.y).toFixed(decimals)} E${emit.toFixed(decimals)}`; - let add = pos.f !== from.speedMMM ? ` E${from.speedMMM}` : ''; - append(`${pre}${add} ; merged=${cl-1} len=${dist.toFixed(decimals)} cp=${cc.x.round(2)},${cc.y.round(2)}`); - pos.x = to.x; - pos.y = to.y; - pos.z = to.z; - } else { - for (let rec of arcQ) { - emitQrec(rec); - } - } - arcQ.length = 0; - arcQ.center = undefined; - } - - // comprehensive arc validator - function arcValid() { - if (arcQ.length < 3) { - return false; - } - - let globalCenters = []; // see how a point first the curve within the context of the arc's end points. - - for (let i = 0; i < arcQ.length - 2; i++) { - let cc = util.center2d(arcQ[0], arcQ[i+1], arcQ[arcQ.length - 1], 1); - if (!cc) { - return false; - } - globalCenters.push(cc); - } - - let ac = { // average center - x:globalCenters.reduce( (t,v) => t + v.x , 0) / (globalCenters.length), - y:globalCenters.reduce( (t,v) => t + v.y , 0) / (globalCenters.length), - z:globalCenters.reduce( (t,v) => t + v.z , 0) / (globalCenters.length), - r:globalCenters.reduce( (t,v) => t + v.r , 0) / (globalCenters.length) - }; - - // make sure centers are within specified tolerance - for (let cc of globalCenters) { - let dc = Math.sqrt(Math.pow(cc.x - ac.x, 2) + Math.pow(cc.y - ac.y, 2)); - if (dc / ac.r > arcDist) { - return false; - } - } - - // make sure radii are within specified tolerance - for (let point of arcQ) { - let rad = Math.sqrt(Math.pow(point.x - ac.x, 2) + Math.pow(point.y - ac.y, 2)); - if (Math.abs(rad - ac.r) > ac.r * arcDist) { - return false; - } - } - - // enforce arcRes(olution) - for (let i = 1; i < arcQ.length; i++) { - let angle = 2 * Math.asin(arcQ[i].dist/(2*ac.r)); - if (Math.abs(angle) > Math.PI * 2 / arcRes) { - return false; - } - } - - // check points in the context of neighbors - for (let i = 0; i < arcQ.length - 2; i++) { - let cc = util.center2d(arcQ[i], arcQ[i+1], arcQ[i+2], 1); - if (!cc || Math.abs((cc.r - ac.r) / cc.r) > arcDev) { - return false; - } - } - - return true; - } - if (inloops) { append(`M808`); } @@ -984,11 +770,13 @@ export function fdm_export(print, online, ondone, ondebug) { subst.time = util.round(time,2); subst['print_time'] = subst.time; - append("; --- shutdown ---"); + // append("; --- shutdown ---"); appendAllSub(device.gcodePost); append(`; --- filament used: ${subst.material} mm ---`); append(`; --- print time: ${time.toFixed(0)}s ---`); + if (pre === 3) preamble(); + // force emit of buffer append(); // console.log({segments, emitted, outputLength}); diff --git a/src/kiri/mode/fdm/fill.js b/src/kiri/mode/fdm/work/fill.js similarity index 98% rename from src/kiri/mode/fdm/fill.js rename to src/kiri/mode/fdm/work/fill.js index 830dec06..d7545b87 100644 --- a/src/kiri/mode/fdm/fill.js +++ b/src/kiri/mode/fdm/work/fill.js @@ -1,7 +1,7 @@ /** Copyright Stewart Allen -- All Rights Reserved */ -import { newPolygon } from '../../../geo/polygon.js'; -import { slice as gyroid_slice } from '../../../geo/gyroid.js'; +import { newPolygon } from '../../../../geo/polygon.js'; +import { slice as gyroid_slice } from '../../../../geo/gyroid.js'; export const fill_fixed = { hex: fillHexFull, diff --git a/src/kiri/mode/fdm/work/init-work.js b/src/kiri/mode/fdm/work/init-work.js new file mode 100644 index 00000000..8e08a9ff --- /dev/null +++ b/src/kiri/mode/fdm/work/init-work.js @@ -0,0 +1,51 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { util } from '../../../../geo/base.js'; +import { sliceOne, slicePost } from './slice.js'; +import { fdm_prepare } from './prepare.js'; +import { fdm_export } from './export.js'; + +// noz = nozzle diameter +// fil = filament diameter +// slice = slice height +function extrudePerMM(noz, fil, slice) { + // if (isNaN(noz)) console.trace({ noz }); + // if (isNaN(fil)) console.trace({ fil }); + // if (isNaN(slice)) console.trace({ slice }); + // (ratio of nozzle to filament) * (ratio of slice to nozzle) + return ((Math.PI * util.sqr(noz / 2)) / (Math.PI * util.sqr(fil / 2))) * (slice / noz); +}; + +// dist = distance between extrusion points +// perMM = amount extruded per MM (from extrudePerMM) +// factor = scaling factor (usually 1.0) +function extrudeMM(dist, perMM, factor) { + // if (isNaN(dist)) console.trace({ dist }); + // if (isNaN(perMM)) console.trace({ perMM }); + // if (isNaN(factor)) console.trace({ factor }); + return dist * perMM * factor; +} + +// defer loading until client and worker exist +function init(worker) { + // worker.dispatch.fdm_support_generate = function(data, send) { + // const { settings } = data; + // const widgets = Object.values(worker.cache); + // const fresh = widgets.filter(widget => supports(settings, widget)); + // send.done(codec.encode(fresh.map(widget => { return { + // id: widget.id, + // supports: widget.supports, + // } } ))); + // }; +} + +export const FDM = { + init, + extrudePerMM, + extrudeMM, + slicePre: undefined, + slice: sliceOne, + slicePost: slicePost, + prepare: fdm_prepare, + export: fdm_export +}; diff --git a/src/kiri/mode/fdm/post.js b/src/kiri/mode/fdm/work/post.js similarity index 95% rename from src/kiri/mode/fdm/post.js rename to src/kiri/mode/fdm/work/post.js index a8e3fda7..5271b5ac 100644 --- a/src/kiri/mode/fdm/post.js +++ b/src/kiri/mode/fdm/work/post.js @@ -1,28 +1,32 @@ /** Copyright Stewart Allen -- All Rights Reserved */ -import '../../../ext/jspoly.js'; -import { base } from '../../../geo/base.js'; -import { newPolygon, Polygon } from '../../../geo/polygon.js'; -import { polygons as POLY, fillArea } from '../../../geo/polygons.js'; -import { getRangeParameters } from './driver.js'; -import { slicer } from '../../../geo/slicer.js'; -import { Point } from '../../../geo/point.js'; -import { Slope } from '../../../geo/slope.js'; +import '../../../../ext/jspoly.js'; +import { base } from '../../../../geo/base.js'; +import { newPolygon, Polygon } from '../../../../geo/polygon.js'; +import { polygons as POLY, fillArea } from '../../../../geo/polygons.js'; +import { getRangeParameters } from '../core/params.js'; +import { slicer } from '../../../../geo/slicer.js'; +import { Point } from '../../../../geo/point.js'; +import { Slope } from '../../../../geo/slope.js'; -slicer.slicePost.FDM = slicePost; +// tell the slicer what to do in FDM mode +// with slices (post processing). the post +// processor may depend on worker / minion context +slicer.slicePost.FDM = slicerPostProcessor; /** * may run in minion or worker context. do not create objects * that will not quickly encode in threaded mode. add to existing * data object. return is ignored. */ -export function slicePost(data, options) { +function slicerPostProcessor(data, options) { const { groups, z } = data; const { post_args, useAssembly, zIndexes } = options; - const { isSynth, process, vaseMode } = post_args; + const { process, vaseMode } = post_args; const { compInner, compOuter, pump } = post_args; const { clipOffset, fillOffset, shellOffset } = post_args; + // pumping the shells will round sharp edges if (pump) { let nugroups = []; let pump = shellOffset / 10; @@ -60,20 +64,6 @@ export function slicePost(data, options) { data.tops = POLY.nest([...inner, ...outer]); } - if (isSynth) { - const process = post_args.process; - if (process.sliceSupportGrow > 0) { - // expand synth supports here so they can be clipped later - data.tops = POLY.expand(data.tops, process.sliceSupportGrow, data.z, []); - } - // do not shell synth widgets because - // they will be clipped against peers later - // which requires shelling post-clip - // but we still need to generate tops - delete data.groups; - return; - } - const index = zIndexes.indexOf(z); const range = getRangeParameters(process, index); // calculate fractional shells @@ -87,19 +77,20 @@ export function slicePost(data, options) { let trg = shellFrac > 0.5 ? 1 : parts - 1; sliceShells += rem >= trg ? 1 : 0; } - const count = isSynth ? 1 : sliceShells; + const count = sliceShells; const offset = shellOffset; const fillOff = fillOffset; - const thinType = isSynth ? undefined : process.sliceDetectThin; + const thinType = process.sliceDetectThin; const nutops = []; // co-locate shell processing with top generation in slicer for (let top of data.tops) { - nutops.push(doTopShells(z, top, count, offset/2, offset, fillOff, { + nutops.push(layerProcessTop(z, top, count, offset/2, offset, fillOff, { thinType, vaseMode, useAssembly })); } + // create a fast clip offset polygon based on a simplified part outline data.clip = clipOffset ? POLY.offset(nutops.map(t => t.simple), clipOffset) : undefined; data.tops = nutops; delete data.groups; @@ -126,7 +117,7 @@ function offset_default(params) { // mark each poly with depth (offset #) starting at 0 for (let p of polys) { p.depth = count - onCount; - if (p.fill_off) p.fill_off.forEach(function(pi) { + if (p.fill_off) p.fill_off.forEach((pi) => { // use negative offset for inners pi.depth = -(count - onCount); }); @@ -145,6 +136,7 @@ function offset_default(params) { return { last, gaps }; } + // basic thin wall testing const deltaBig = 160; const deltaSmall = 20; @@ -698,7 +690,7 @@ function trace_noodle(noodle, noodleWidth, minR, midR, maxR, opt = {}) { * may run in minion or worker context. performs shell offsetting * including thin wall detection when enabled */ -export function doTopShells(z, top, count, offset1, offsetN, fillOffset, opt = {}) { +export function layerProcessTop(z, top, count, offset1, offsetN, fillOffset, opt = {}) { // pretend we're a top object in minions if (!top.poly) { top = { poly: top }; @@ -766,6 +758,9 @@ export function doTopShells(z, top, count, offset1, offsetN, fillOffset, opt = { last = [top.poly]; } + // renest deep because last[] could be nested + last = POLY.renest(last, true); + // generate fill offset poly set from last offset to top.fill_off if (fillOffset && last.length > 0) { // if gaps present, remove that area from fill inset @@ -774,7 +769,7 @@ export function doTopShells(z, top, count, offset1, offsetN, fillOffset, opt = { POLY.subtract(last, gaps, nulast, null, z); last = nulast; } - last.forEach(function(inner) { + last.forEach((inner) => { POLY.offset([inner], -fillOffset, {outs: top.fill_off, flat: true, z}); }); } diff --git a/src/kiri/mode/fdm/prepare.js b/src/kiri/mode/fdm/work/prepare.js similarity index 81% rename from src/kiri/mode/fdm/prepare.js rename to src/kiri/mode/fdm/work/prepare.js index 8fd835b3..08c439d8 100644 --- a/src/kiri/mode/fdm/prepare.js +++ b/src/kiri/mode/fdm/work/prepare.js @@ -1,17 +1,17 @@ /** Copyright Stewart Allen -- All Rights Reserved */ -import { FDM } from './driver-be.js'; -import { base, util } from '../../../geo/base.js'; -import { poly2polyEmit, tip2tipEmit } from '../../../geo/paths.js'; -import { newBounds } from '../../../geo/bounds.js'; -import { newPoint, Point } from '../../../geo/point.js'; -import { newPolygon, Polygon } from '../../../geo/polygon.js'; -import { polygons as POLY, fillArea } from '../../../geo/polygons.js'; -import { newPrint } from '../../core/print.js'; -import { newSlice } from '../../core/slice.js'; -import { newWidget } from '../../core/widget.js'; -import { render } from '../../core/render.js'; -import { getRangeParameters } from './driver.js'; +import { FDM } from './init-work.js'; +import { base, util } from '../../../../geo/base.js'; +import { poly2polyEmit, tip2tipEmit } from '../../../../geo/paths.js'; +import { newBounds } from '../../../../geo/bounds.js'; +import { newPoint, Point } from '../../../../geo/point.js'; +import { newPolygon, Polygon } from '../../../../geo/polygon.js'; +import { polygons as POLY, fillArea } from '../../../../geo/polygons.js'; +import { newPrint } from '../../../core/print.js'; +import { newSlice } from '../../../core/slice.js'; +import { newWidget } from '../../../core/widget.js'; +import { render } from '../../../core/render.js'; +import { getRangeParameters } from '../core/params.js'; const { numOrDefault } = util; @@ -26,7 +26,7 @@ export async function fdm_prepare(widgets, settings, update) { widgets = widgets.filter(w => !w.track.ignore && !w.meta.disabled); let { device, process, controller, bounds } = settings, - { sliceHeight, firstSliceHeight, firstLayerRate } = process, + { sliceHeight, firstSliceHeight, firstLayerRate, sliceSupportNozzle } = process, { outputSeekrate, outputDraftShield, outputPurgeTower } = process, { bedWidth, bedDepth } = device, { lineType } = controller, @@ -47,6 +47,9 @@ export async function fdm_prepare(widgets, settings, update) { output = [], layerout = []; + // wait for safe eval setup + await print.ready(); + // compute bounds if missing if (!bounds) { bounds = new THREE.Box3(); @@ -83,17 +86,12 @@ export async function fdm_prepare(widgets, settings, update) { slice.tops.forEach(function(top) { tops.push(top.poly.clone()); }); - // collect support polygons - if (slice.supports) - slice.supports.forEach(function(support) { - tops.push(support.clone()); - }); } if (outputDraftShield) { tops = POLY.union(tops,1,true); } // nest and offset tops - POLY.nest(tops).forEach(function(poly) { + POLY.nest(tops).forEach((poly) => { let off = poly.offset(-offset + nozzle / 2); if (off) off.forEach(function(brim) { brim.move(widget.track.pos); @@ -113,7 +111,7 @@ export async function fdm_prepare(widgets, settings, update) { brims = POLY.expand(brims, -extra, zheight, null, 1); } - // if raft is specified + // create raft when specified if (useRaft) { let offset = newPoint(0,0,0), height = nozzle, @@ -203,6 +201,7 @@ export async function fdm_prepare(widgets, settings, update) { preout.last().retract = true; } } + // recompute bounds for purge block offsets let bbounds = newBounds(); brims.forEach(brim => { @@ -214,39 +213,6 @@ export async function fdm_prepare(widgets, settings, update) { bounds.max.y = Math.max(bounds.max.y, bbounds.maxy); } - // synthesize support widgets when needed - // so that they can use a separate extruder - // compute zmin for belt purge towers - for (let widget of widgets.slice()) { - let sslices = []; - if (!widget.slices) { - console.log('invalid widget', widget); - continue; - } - for (let slice of widget.slices) { - zmin = Math.min(zmin, slice.z); - zmax = Math.max(zmax, slice.z); - if (!slice.supports) { - continue; - } - let sslice = newSlice(slice.z); - sslice.extruder = process.sliceSupportNozzle; - sslice.supports = slice.supports.slice(); - sslice.height = slice.height; - sslices.push(sslice); - } - if (sslices.length) { - let swidget = newWidget(null,widget.group); - swidget.slices = sslices; - swidget.support = true; - swidget.belt = widget.belt; - swidget.track = Object.clone(widget.track); - swidget.mesh = { widget: swidget, position: swidget.track.pos }; - widget.anno.extruder = process.sliceSupportNozzle; - widgets.push(swidget); - } - } - let lastPoly; let lastLayer; let lastOffset; @@ -255,19 +221,38 @@ export async function fdm_prepare(widgets, settings, update) { // find max layers (for updates) // generate list of used extruders for purge blocks - for (let widget of widgets) { - let extruder = widget.anno.extruder ?? 0; + for (let extruder of widgets.map(w => w.anno.extruder ?? 0)) { if (!extruders[extruder]) { extruders[extruder] = {}; extcount++; } } + // add support tool, if not already used + if (!extruders[sliceSupportNozzle]) { + extruders[sliceSupportNozzle] = {}; + extcount++; + } // determine size/location of purge blocks let blokw = Math.sqrt(outputPurgeTower || 0); let blokh = blokw; let blokpos, walkpos, blok; + /** + * mkblok() - Calculate purge tower block positions and layout + * + * Sets up the geometric layout for purge tower blocks. Each extruder swap requires + * a purge block, so we create (extcount-1) blocks arranged either horizontally or + * vertically depending on the print bed orientation. + * + * @param {number} w - width of each purge block + * @param {number} h - height of each purge block + * + * Sets module-level variables: + * - blokpos: starting position {x,y} for first block + * - walkpos: step vector {x,y} between blocks + * - blok: dimensions {x,y} of each block + */ function mkblok(w,h) { let count = extcount - 1; let gap = nozzle; @@ -301,6 +286,23 @@ export async function fdm_prepare(widgets, settings, update) { } } + /** + * mkrec() - Create a purge tower block record for a specific extruder + * + * Generates the geometry for one purge block including outline, dense fill, + * and sparse fill patterns. Position is calculated based on the block index. + * + * @param {number} i - extruder index in device.extruders array (NOT sparse extruders index) + * @param {number} angle - fill pattern angle in degrees (default 45) + * @param {number} thin - spacing multiplier for sparse fill (default 6) + * @returns {Object} purge block record with: + * - extruder: extruder index + * - diameter: nozzle diameter + * - rect: outer rectangle polygon + * - full: dense fill pattern for first layer or tool changes + * - sparse: sparse fill pattern for maintaining tower structure + * - pause: position for any pause commands + */ function mkrec(i, angle = 45, thin = 6) { let exi = device.extruders[i], noz = exi.extNozzle, @@ -326,10 +328,26 @@ export async function fdm_prepare(widgets, settings, update) { mkblok(blokw, blokh); // allocate tower space for extruders-1 locations - let towers = extruders.slice(1).map((ext,i) => { - return ext ? mkrec(i) : ext; + // for example, with colors, you only need to swap once + // on each layer then start the next layer on the + // currently selected tool. + // IMPORTANT: must use actual extruder indices, not array indices, + // to handle non-contiguous tool usage (e.g., tool 0 + tool 2) + let usedExtruderIndices = []; + for (let i = 0; i < extruders.length; i++) { + if (extruders[i]) { + usedExtruderIndices.push(i); + } + } + let towers = usedExtruderIndices.slice(1).map((extruderIndex, blockIndex) => { + return mkrec(extruderIndex, 45 + blockIndex * 90); }); + // console.log({ + // extruders: extruders.slice(), + // towers: towers.slice() + // }); + function linesToPoly(points) { let poly = newPolygon().setOpen(); let ping = 0; @@ -350,25 +368,64 @@ export async function fdm_prepare(widgets, settings, update) { let purgedFirst = false; let lastPurgeTool; - // generate purge block for given nozzle + /** + * purge() - Generate purge tower block output for a given tool + * + * Called in two contexts: + * 1. Real purge: When switching tools during printing (using=undefined) + * 2. Fill-in: At layer end to maintain unused blocks (using=actual tool index) + * + * The function consumes one purge block from the track array and generates: + * - Outline perimeter (with extrusion) + * - Fill pattern (dense for real purges, sparse for fill-ins) + * - Retract + * - Optional wipe move (outline without extrusion) + * + * @param {number} nozzle - The extruder index that needs purging/filling + * @param {Array} track - Array of available purge block records (mutated via shift()) + * @param {Array} layer - Output layer array to append print moves to + * @param {Point} start - Current print head position + * @param {number} z - Z height for this layer + * @param {number} using - Optional override tool index: + * - undefined/negative: real purge (use nozzle param) + * - >= 0: fill-in mode (use this tool instead) + * @param {Object} offset - Position offset for belt printers + * @returns {Point} Updated print head position after purge block + * + * Fill pattern selection: + * - Dense fill (rec.full): Used for first layer or real tool changes + * - Sparse fill (rec.sparse): Used for fill-ins or consecutive same-tool purges + */ function purge(nozzle, track, layer, start, z, using, offset) { if (!outputPurgeTower || extcount < 2) { return start; } + // Get next available purge block (consumes from track array) let rec = track.shift(); + + // Use sparse fill if: filling someone else's block OR same tool as last purge let thin = using >= 0 || lastPurgeTool === nozzle; + + // Actual tool to use: override tool for fill-ins, otherwise the nozzle being purged let tool = using >= 0 ? using : nozzle; + + // Detect first layer for special handling let first = isBelt ? !purgedFirst && layer.slice.index >= 0 : layer.slice.index === 0; let rate = first ? process.firstLayerRate : process.outputFeedrate; let wipe = true; lastPurgeTool = tool; + if (rec) { print.setType('purge tower'); if (layer.last()) { layer.last().retract = true; } purgedFirst = purgedFirst || first; + + // Use dense fill for first layer or real purges (when thin=false) let purgeOn = first || !thin; + + // Belt printer: scale tower size for negative Z (before belt contact) if (isBelt && z < 0) { let scale = 1 - ((z / zmin)); mkblok(blokw * scale, blokh); @@ -376,9 +433,12 @@ export async function fdm_prepare(widgets, settings, update) { rate = (process.outputFeedrate - process.firstLayerRate) * scale + process.firstLayerRate; wipe = false; } + // Prepare geometry: outline, pause point, and fill pattern (dense or sparse) let box = rec.rect.clone().setZ(z); let pause = rec.pause.clone().setZ(z); let fill = (z >= 0 && purgeOn ? rec.full : rec.sparse).clone().setZ(z); + + // Belt printer: apply Y/Z coordinate transformations if (isBelt) { let bmove = {x:0, y:z, z:0}; box.move(bmove); @@ -393,6 +453,8 @@ export async function fdm_prepare(widgets, settings, update) { if (fill) fill.move(bo); } } + + // Output sequence: 1. Outline perimeter (with extrusion) start = print.polyPrintPath(box, start, layer, { tool, rate, @@ -400,8 +462,9 @@ export async function fdm_prepare(widgets, settings, update) { open: false, onfirstout: (out => out.overate = (isBelt ? rate : 0)) }); + + // Output sequence: 2. Fill pattern (with extrusion, dense or sparse) if (fill && fill.length) { - // for pings, split path at 20mm start = print.polyPrintPath(fill, start, layer, { tool, rate, @@ -410,8 +473,11 @@ export async function fdm_prepare(widgets, settings, update) { onfirst: (point) => { point.purgeOn = purgeOn ? pause : undefined } }); } + + // Output sequence: 3. Retract after fill layer.last().retract = true; - // experimental post-retract wipe + + // Output sequence: 4. Post-retract wipe (outline without extrusion) if (wipe) start = print.polyPrintPath(box, start, layer, { tool, rate, @@ -427,15 +493,25 @@ export async function fdm_prepare(widgets, settings, update) { } } - // establish offsets + /** + * Establish widget offsets + * + * For belt printers: Apply trigonometric transformation from Y-axis position + * to inclined belt coordinate system using widget.belt properties (cosf, sinf, ypos) + * + * For normal printers: Use widget position plus any raft offset + * + * Note: This must run BEFORE support widgets are created, as they inherit + * both the offset and belt properties from their parent widget. + */ for (let widget of widgets) { let { belt } = widget; let offset = Object.clone(widget.track.pos); if (isBelt) { offset = { x: belt.xpos, - y: belt.ypos * belt.cosf, - z: belt.ypos * belt.sinf + y: belt.ypos * belt.cosf, // Y component of inclined belt + z: belt.ypos * belt.sinf // Z component of inclined belt }; } else { // when rafts used this is non-zero @@ -444,14 +520,48 @@ export async function fdm_prepare(widgets, settings, update) { widget.offset = offset; } + /** + * Extract support structures into separate synthetic widgets + * + * Support structures use a different extruder (sliceSupportNozzle) and need + * to be processed independently. This creates new "virtual" widgets containing + * only support geometry. + * + * CRITICAL for belt mode: Must copy belt properties to maintain proper positioning + * - widget.belt: Required for offset calculations if widget list is reprocessed + * - slice.belt.anchor: Used to mark layers that touch the belt (line 667) + * - slice.belt.touch: Affects print start position logic (line 676) + * + * Without belt property copying, supports render in incorrect positions because + * the belt coordinate transformations (cosf, sinf, ypos) are not applied. + */ + for (let widget of widgets.slice()) { + let slices = []; + for (let slice of widget.slices) { + if (slice.supports) { + let nslice = newSlice(slice.z); + nslice.belt = slice.belt; + nslice.height = slice.height; + nslice.index = slice.index; + nslice.supports = slice.supports; + nslice.extruder = sliceSupportNozzle; + slices.push(nslice); + } + } + if (slices.length) { + let nwid = newWidget(null, widget.group); + nwid.support = true; + nwid.slices = slices; + nwid.offset = widget.offset; // inherit calculated offset from parent + nwid.belt = widget.belt; // inherit any belt transformations + widgets.push(nwid); + } + } + // create shuffled slice cake by z offset (slice.z + offset.z) let cake = []; let zrec = {}; for (let widget of widgets) { - // skip synthesized support widget(s) - if (!widget.mesh) { - continue; - } for (let slice of widget.slices) { slice.widget = widget; let z = (slice.z + widget.offset.z).round(2); @@ -466,26 +576,55 @@ export async function fdm_prepare(widgets, settings, update) { return a.z - b.z; }); - let firstTool; - let lastWidget; - let lastExt; - let lastOut; + let firstTool; // First extruder used (determines starting extruder) + let lastWidget; // Last widget printed (for retraction decisions) + let lastExt; // Last extruder used (for minimizing tool changes) + let lastOut; // Last output slice (for tracking state) print.zmax = zmax; print.total_time = 0; - // walk cake layers bottom up + /** + * MAIN PRINT LOOP: Walk cake layers bottom up + * + * The "cake" is a sorted array of layers where each layer contains all slices + * at that Z height from all widgets. This loop processes layers in Z order. + * + * For each layer: + * 1. Process all slices using greedy nearest-neighbor with extruder penalties + * 2. Generate purge tower blocks on extruder changes + * 3. Add optional draft shield around perimeter + * 4. Fill any unused purge blocks to maintain tower structure + * 5. Append completed layer to output and update progress + * + * Key optimization: Minimize travel and extruder changes by: + * - Prioritizing slices using the same extruder (10000x distance penalty for changes) + * - Choosing nearest slice within same-extruder group + * - Starting first object with extruder 0 when possible + */ for (let layer of cake) { - // track purge blocks generated for each layer + // Clone towers array to track available purge blocks for this layer + // Each purge block can only be used once per layer let track = towers.slice(); - // iterate over layer slices, find closest widget, print, eliminate + /** + * INNER LOOP: Process all slices in this layer + * + * Uses greedy nearest-neighbor algorithm with extruder awareness: + * - Builds a priority queue of unprinted slices + * - Heavily penalizes extruder changes (10000x distance multiplier) + * - Selects closest slice with preferred extruder + * - Marks as prep'd and removes from consideration + * - Repeats until all slices processed (order.length === 0) + */ for (;;) { + // Build priority queue of unprinted slices with weighted distances let order = []; - // select slices of the same extruder type first then distance + + // Evaluate each unprinted slice in this layer for (let slice of layer.slices) { if (slice.prep) { - continue; + continue; // Skip already processed slices } let offset = lastOffset = slice.widget.offset; let find = slice.findClosestPointTo(printPoint.sub(offset)); @@ -493,69 +632,91 @@ export async function fdm_prepare(widgets, settings, update) { let ext = slice.extruder; let lex = lastExt; let dst = Math.abs(find.distance); - // penalize extruder swaps + + // CRITICAL: Heavily penalize extruder changes to minimize purging + // 10000x multiplier makes any same-extruder slice preferred over a swap if (ext !== lex) { dst *= 10000; } - // for first object, penalize extruders other than first + + // CRITICAL: For first slice in print, prefer extruder 0 + // Ensures print starts with primary extruder when possible if (lastExt === undefined && ext > 0) { dst *= 10000; } + order.push({dst, slice, offset, z: layer.z}); } } + + // All slices processed for this layer - exit inner loop if (order.length === 0) { break; } + + // Sort by weighted distance (extruder penalty + travel distance) order.sort((a,b) => { return a.dst - b.dst; }); + + // Select nearest slice (considering extruder penalties) let { z, slice, offset } = order[0]; + // Track the first tool used to determine print starting extruder if (firstTool === undefined) { firstTool = slice.extruder; } - // when layers switch between widgets, force retraction + // Force retraction when switching between different widgets + // Prevents stringing during travel moves between objects let forceRetract = lastOut && lastOut.widget !== slice.widget; if (forceRetract && output.length) { - // selecet last output of last layer output.last().last().retract = true; } + // Get any layer-specific parameter overrides for this slice let params = getRangeParameters(process, slice.index); + + // Mark slice as processed so it won't be selected again slice.prep = true; - // retract between widgets or layers (when set) + + // Retract between widgets within the same layer if (layerout.length && slice.widget !== lastWidget) { layerout.last().retract = true; } + + // Update layer metadata for this slice lastWidget = slice.widget; layerout.z = z + slice.height / 2; layerout.height = layerout.height || slice.height; layerout.slice = slice; layerout.params = params; - // mark layer as anchor if slice is belt and flag set - layerout.anchor = slice.belt && slice.belt.anchor; - // detect extruder change and print purge block + layerout.anchor = slice.belt && slice.belt.anchor; // Belt anchor flag + + // PURGE TOWER: Generate purge block on extruder change + // Only purge when switching to a different extruder if (!lastOut || lastOut.extruder !== slice.extruder) { if (slice.extruder >= 0) printPoint = purge(slice.extruder, track, layerout, printPoint, slice.z, undefined, offset); } let wtb = slice.widget.track.box; - let beltStart = slice.belt && slice.belt.touch;// && (widgets.length === 1); - // output seek to start point between mesh slices if previous data + let beltStart = slice.belt && slice.belt.touch; + + // PRINT THIS SLICE: Generate all toolpaths (shells, infill, etc.) print.setType('layer'); print.setWidget(lastWidget); printPoint = slicePrintPath( print, slice, + // Belt prints start from far corner, others from current position beltStart ? newPoint(-5000, 5000, 0) : printPoint.sub(offset), offset, layerout, { first: slice.index === 0, onBelt: beltStart, - params, // range parameters + params, // layer-specific parameter overrides pretract: (wipeDist) => { + // Optional wipe move before retraction if (!(lastLayer && lastLayer.length)) { return; } @@ -572,36 +733,45 @@ export async function fdm_prepare(widgets, settings, update) { }, routeAround: process.outputAvoidGaps, seedPoint: printPoint.sub(offset), - support: slice.widget.support, zmax, } ); + + // Update print time statistics layerout.print_time = printPoint.layer_time; print.total_time += printPoint.layer_time; print.setWidget(null); + // Update state for next iteration lastOut = slice; lastExt = lastOut.extruder; lastPoly = slice.lastPoly; lastLayer = layerout; + // Optional retraction after every layer if (params.outputLayerRetract && layerout.length) { layerout.last().retract = true; } } + // End of inner loop - all slices in this layer have been processed - // clear slice.prep so it can be re-previewed in a different mode + /** + * POST-PROCESSING: Layer finalization steps + */ + + // Clear prep flags to allow re-preview in different modes + // Only clear real widget slices, not synthesized support widgets for (let widget of widgets) { - // skip synthesized support widget(s) if (!widget.mesh) { - continue; + continue; // Skip synthesized support widgets } for (let slice of widget.slices) { slice.prep = false; } } - // draft shield + // DRAFT SHIELD: Print perimeter around all objects (layers 1+) + // Helps contain heat and protects from drafts for better print quality if (layerno > 0 && shield && outputDraftShield) { print.setType('shield'); shield = POLY.setZ(shield.clone(), printPoint.z); @@ -610,7 +780,6 @@ export async function fdm_prepare(widgets, settings, update) { return print.polyPrintPath(poly, startPoint, preout, { onfirst: function(point) { if (preout.length && point.distTo2D(startPoint) > 2) { - // retract between part and shield preout.last().retract = true; } } @@ -620,27 +789,31 @@ export async function fdm_prepare(widgets, settings, update) { layerout.appendAll(preout); } - // if a declared extruder isn't used in a layer, use selected - // extruder to fill the relevant purge blocks for later support + // FILL UNUSED PURGE BLOCKS: Maintain structural integrity of purge tower + // Any purge blocks not used for real tool changes get filled with sparse + // infill using the current extruder. This keeps the tower solid for support. if (lastOut) track.slice().forEach(ext => { + // using=lastExt means "fill with current tool" (sparse fill mode) printPoint = purge(ext.extruder, track, layerout, printPoint, lastOut.z, lastExt, lastOffset); }); - // if layer produced output, append to output array + // Append completed layer to output array if (layerout.length) { output.append(layerout); } - // retract after last layer + // Final retraction after last layer of print if (layerno === cake.length - 1 && layerout.length) { layerout.last().retract = true; } - // notify progress + // Update progress callback (prepare phase is 0-50%) layerout.layer = layerno++; update((layerno / cake.length) * 0.5, "prepare"); + // Reset for next layer layerout = []; + layerout.height = sliceHeight; } print.output = output; @@ -811,7 +984,6 @@ function slicePrintPath(print, slice, startPoint, offset, output, opt = {}) { const { settings } = print; const { device } = settings; const { bedWidth, bedDepth, bedRound } = device; - const { extrudePerMM } = FDM; let preout = [], process = opt.params || settings.process, @@ -821,7 +993,7 @@ function slicePrintPath(print, slice, startPoint, offset, output, opt = {}) { minLayerTime = process.outputMinLayerTime || 0, nozzleSize = process.sliceLineWidth || device.extruders[extruder].extNozzle, nozzles = device.extruders.map(e => e.extNozzle), - firstLayer = (opt.first || false) && !opt.support, + firstLayer = (opt.first || false), thinWall = nozzleSize * (opt.thinWall || 1.75), retractDist = opt.retractOver || (nozzleSize * 5), fillMult = opt.mult || process.outputFillMult, @@ -845,6 +1017,11 @@ function slicePrintPath(print, slice, startPoint, offset, output, opt = {}) { maxFlowRate = process.outputMaxFlowrate, wipeDist = process.outputRetractWipe || 0, isBelt = device.bedBelt, + { fdmArcEnabled, fdmArcTolerance, fdmArcResolution } = process, + arcOpts = { + tolerance: fdmArcTolerance, + arcRes: fdmArcResolution + }, bedOffset = originCenter ? { x: 0, y: 0, @@ -871,10 +1048,12 @@ function slicePrintPath(print, slice, startPoint, offset, output, opt = {}) { printSpeed = finishSpeed || printSpeed; } - // override: adapt bridge layer speed - if (slice.isBridgeLayer) { + // override: adapt bridge layer speed and flats + // but not on the first layer which has it's own settings + if (slice.index > 0 && (slice.isBridgeLayer || slice.isFlatsLayer)) { fillSpeed /= 2; printSpeed /= 2; + finishSpeed /= 2; } function retract() { @@ -1102,6 +1281,7 @@ function slicePrintPath(print, slice, startPoint, offset, output, opt = {}) { let scarf = (slice.index > 0 && traceNo === 0 && z - startPoint?.z > 1e-3) ? scarfLength : 0; startPoint = print.polyPrintPath(poly, startPoint, preout, { accel: finishShell, + arcOpts: fdmArcEnabled ? arcOpts : undefined, ccw: opt.shell && process.outputAlternating && slice.index % 2, coast: firstLayer ? 0 : coastDist, extrude: numOrDefault(opt.extrude, shellMult), @@ -1469,7 +1649,7 @@ function slicePrintPath(print, slice, startPoint, offset, output, opt = {}) { if (slice.tops) { out.appendAll(slice.tops); } - if (opt.support && slice.supports) { + if (slice.supports && slice.widget.support) { out.appendAll(slice.supports); } diff --git a/src/kiri/mode/fdm/work/slice.js b/src/kiri/mode/fdm/work/slice.js new file mode 100644 index 00000000..86055429 --- /dev/null +++ b/src/kiri/mode/fdm/work/slice.js @@ -0,0 +1,1728 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { layerProcessTop } from './post.js'; +import { fill, fill_fixed } from './fill.js'; +import { generateBeltAnchor, embossBeltPooch, finalizeBeltBounds } from './belt.js'; +import { getRangeParameters } from '../core/params.js'; +import { newPoint } from '../../../../geo/point.js'; +import { newPolygon } from '../../../../geo/polygon.js'; +import { newSlice } from '../../../core/slice.js'; +import { polygons as POLY } from '../../../../geo/polygons.js'; +import { slice, sliceZ } from '../../../../geo/slicer.js'; +import { util } from '../../../../geo/base.js'; + +const CONSTANTS = { + // Support fill + SUPPORT_FILL_LEGACY_OFFSET: 1000, // hack to allow old code path detection (line 158) + SUPPORT_AUTO_ANGLE_WIDE: 1090, // auto-angle for wide support polygons (90°) + SUPPORT_AUTO_ANGLE_TALL: 1000, // auto-angle for tall support polygons (0°) + SUPPORT_INSET_RATIO: 1/3, // inset support fill by 33% of line width + SUPPORT_CONNECT_DISTANCE_MULT: 2, // connect fill lines within 2x spacing + + // Minimum areas and thresholds + MIN_POLY_AREA: 0.1, // minimum polygon area in mm² + DENSE_INFILL_THRESHOLD: 0.995, // 99.5% infill triggers solid layer + SOLID_LAYER_COVERAGE_THRESHOLD: 0.5, // 50% solid coverage triggers full layer + + // Adaptive slicing + ADAPTIVE_SLICE_TOLERANCE: 0.01, // threshold for adaptive layer height adjustments + + // Belt mode + BELT_PRECISION_ROUNDING: 3, // decimal places for belt touch detection + BELT_PEEK_LAYERS: 5, // layers to check for brim width calculation + BELT_ANCHOR_BUMP_SPACING_MULT: 2, // anchor bump spacing multiplier + BELT_MIN_WIDTH_FOR_HATCH: 10, // minimum width in mm for hatch lines in bumps + BELT_HATCH_SPACING: 3, // hatch line spacing in mm for bumps + + // Pooch (belt text embossing) + POOCH_Y_RANGE_MIN: 0, // minimum Y offset for pooch detection + POOCH_Y_RANGE_MAX: 3, // maximum Y offset for pooch detection + POOCH_FLATNESS_TOLERANCE: 0.01, // flatness tolerance for pooch surface + POOCH_WIDTH_TOLERANCE: 1, // width matching tolerance in mm + POOCH_DY_TOLERANCE: 0.1, // height delta tolerance + POOCH_DZ_TOLERANCE: 1, // length delta tolerance + POOCH_TEXT_SCALE_X: 1.2, // text horizontal scale factor + POOCH_TEXT_SCALE_Y: 1, // text vertical scale factor + POOCH_FONT_SIZE: 24, // font size in pixels + POOCH_PIXEL_THRESHOLD: 30, // brightness threshold for pixel detection + + // Angled fill + ANGLED_FILL_ROTATION: 45, // rotation offset for angled fills +}; + +/** + * @typedef {Object} SliceParams + * @property {number} sliceHeight - Layer height in mm + * @property {number} sliceLineWidth - Extrusion width in mm + * @property {number} sliceTopLayers - Number of solid top layers + * @property {number} sliceBottomLayers - Number of solid bottom layers + * @property {number} sliceFillSparse - Infill density (0.0-1.0) + * @property {string} sliceFillType - Infill pattern: 'hex', 'grid', 'gyroid', 'triangle', etc. + * @property {number} sliceFillAngle - Base infill angle in degrees + * @property {number} sliceFillWidth - Solid fill line width multiplier + * @property {number} sliceFillGrow - Fill boundary expansion in mm + * @property {number} sliceFillOverlap - Fill overlap with shells (0.0-0.8) + * @property {number} sliceSolidMinArea - Minimum area in mm² for solid regions + * @property {number} sliceSolidMinThick - Miminum "thickness" ratio for culling thin solids + * @property {number} sliceSupportDensity - Support fill density (0.0-1.0) + * @property {number} sliceSupportFill - Support fill angle (or auto if >= 1000) + * @property {number} sliceSupportGap - Gap between support and part in mm + * @property {boolean} sliceSupportOutline - Include support perimeter + * @property {number} sliceSupportOffset - Support clip offset in mm + * @property {number} sliceAdaptive - Enable adaptive layer heights + * @property {number} sliceMinHeight - Minimum adaptive layer height in mm + * @property {number} firstSliceHeight - First layer height in mm + * @property {number} firstLayerBrim - Brim width in mm + * @property {number} beltAnchor - Belt anchor length in mm + * @property {number} firstLayerBeltLead - Belt lead-in length in mm + * @property {number} firstLayerBeltBump - Belt anchor bump height in mm + * @property {number} firstLayerFlatten - Belt base flattening height in mm + */ + +/** + * @typedef {Object} RenderContext + * @property {boolean} isThin - Force thin line rendering + * @property {boolean} isFlat - Force flat polygon rendering + * @property {number} offset - Line generation offset (typically lineWidth/2) + */ + +/** + * @typedef {Object} FillOptions + * @property {Object} settings - Global settings object + * @property {SliceParams} process - Process parameters + * @property {Object} device - Device configuration + * @property {number} lineWidth - Extrusion width in mm + * @property {number} spacing - Fill line spacing in mm + * @property {number} density - Fill density (0.0-1.0) + * @property {Object} bounds - Widget bounding box + * @property {number} height - Layer height in mm + * @property {string} type - Fill pattern type + * @property {boolean} cache - Enable fill caching + * @property {Promise[]} [promises] - Array for async operations + */ + +/** + * @typedef {Object} SupportFillOptions + * @property {Slice} slice - Slice to process + * @property {number} lineWidth - Extrusion width in mm + * @property {number} density - Support density (0.0-1.0) + * @property {number} minArea - Minimum polygon area in mm² + * @property {boolean} isBelt - Belt printer mode + * @property {number} [angle] - Fill angle in degrees (auto if >= 1000) + * @property {boolean} outline - Include support outline + * @property {number} [gap] - Gap from part surfaces in mm + * @property {Promise[]} [promises] - Array for async operations + */ + +/** + * @typedef {Object} BeltData + * @property {number} slope - Belt angle slope factor + * @property {number} cosf - Cosine of belt angle + * @property {number} sinf - Sine of belt angle + * @property {number} miny - Minimum Y coordinate + * @property {number} midy - Middle Y coordinate + * @property {boolean} [touch] - Whether slice touches belt + * @property {boolean} [anchor] - Whether slice is part of anchor + */ + +let tracker = util.pwait, + lopacity = 0.6, + opacity = 1, + fat = 1.5, + COLOR = { + anchor: { check: 0x999933, face: 0x999933, line: 0x999933, opacity, lopacity, fat }, + fill: { check: 0x005588, face: 0x005588, line: 0x005588, opacity, lopacity, fat }, + gaps: { check: 0xaa3366, face: 0xaa3366, line: 0xaa3366, opacity, lopacity, fat }, + infill: { check: 0x3322bb, face: 0x3322bb, line: 0x3322bb, opacity, lopacity, fat }, + inset: { line: 0xaaaaaa, check: 0xaaaaaa, face: 0 }, + shell: { check: 0x0077bb, face: 0x0077bb, line: 0x0077bb, opacity, lopacity, fat }, + support: { check: 0xbbbb00, face: 0xbbbb00, line: 0xbbbb00, opacity, lopacity, fat }, + part: { line: 0x333333, check: 0x333333 }, + thin: { check: 0xbb8800, face: 0xbb8800, line: 0xbb8800, opacity, lopacity, fat }, + }, + COLOR_DARK = Object.assign({}, COLOR, { + fill: { check: 0x005588, face: 0x005588, line: 0x005588, opacity, lopacity, fat }, + infill: { check: 0x3322bb, face: 0x3322bb, line: 0x3322bb, opacity, lopacity, fat }, + inset: { line: 0x555555, check: 0x555555 }, + part: { line: 0x777777, check: 0x777777, face: 0 }, + thin: { check: 0xbb8800, face: 0xbb8800, line: 0xbb8800, opacity, lopacity, fat }, + }), + noop = () => {}, + PROTO = Object.clone(COLOR), + profile = false, + profileStart = profile ? console.profile : noop, + profileEnd = profile ? console.profileEnd : noop, + debug = false; + +/** + * Apply visualization options based on render context + * + * @param {Object} opt - Base options + * @param {RenderContext} ctx - Render context + * @returns {Object|null} Modified options or null + */ +function vopt(opt, ctx) { + if (opt) { + if (ctx.isFlat) { + opt.flat = true; + opt.outline = true; + return opt; + } + if (ctx.isThin) return null; + } + return opt; +} + +/** + * DRIVER SLICE CONTRACT + * + * Given a widget and settings object, call functions necessary to produce + * slices and then the computations using those slices. This function is + * designed to run client or server-side and provides all output via + * callback functions. + * + * @param {Object} settings + * @param {Widget} Widget + * @param {Function} onupdate (called with % complete and optional message) + * @param {Function} ondone (called when complete with an array of Slice objects) + */ +export function sliceOne(settings, widget, onupdate, ondone) { + let render = settings.render !== false, + { minions } = self.kiri_worker, + { process, device, controller } = settings, + { devel, assembly, threaded } = controller, + isBelt = device.bedBelt, + isBrick = devel && process.sliceZInterleave, + useAssembly = assembly, + isConcurrent = threaded && minions.concurrent && !process.xray, + topLayers = process.sliceTopLayers || 0, + bottomLayers = process.sliceBottomLayers || 0, + vaseMode = process.sliceFillType === 'vase', + metadata = widget.anno, + maxtruder = Math.max(0, device.extruders.length - 1), + extruder = Math.min(maxtruder, parseInt(metadata.extruder || 0)), + sliceHeight = process.sliceHeight, + sliceHeightBase = (isBelt ? sliceHeight : process.firstSliceHeight) || sliceHeight, + lineWidth = process.sliceLineWidth || device.extruders[extruder].extNozzle, + fillOffsetMult = 1.0 - bound(process.sliceFillOverlap, 0, 0.8), + shellOffset = lineWidth, + fillSpacing = lineWidth, + fillOffset = lineWidth * fillOffsetMult, + clipOffset = process.sliceSupportOffset + lineWidth / 2, + sliceFillAngle = process.sliceFillAngle, + supportDensity = process.sliceSupportDensity, + { sliceCompInner, sliceCompOuter } = process; + + // create render context for visualization + /** @type {RenderContext} */ + const renderContext = { + isFlat: controller.lineType === "flat", + isThin: controller.lineType === "line", + offset: lineWidth / 2 + }; + renderContext.isThin = !renderContext.isFlat && renderContext.isThin; + + // allow overriding support fill auto angle algorithm + // also causes support fill to be aligned on start boundaries + // best with angles that are a multiple of 90 degrees + if (process.sliceSupportFill >= 0) { + // yes, an ugly hack to allow it to pass through old code paths + process.sliceSupportFill += CONSTANTS.SUPPORT_FILL_LEGACY_OFFSET; + } + + if (renderContext.isFlat) { + Object.values(COLOR).forEach(color => { + color.flat = true; + color.line = 1 + color.opacity = 1; + }); + } else { + Object.keys(COLOR).forEach(key => { + const color = COLOR[key]; + const proto = PROTO[key] + color.flat = proto.flat; + color.line = proto.line; + color.opacity = proto.opacity; + }); + } + + if (!(sliceHeight > 0 && sliceHeight < 100)) { + return ondone("invalid slice height"); + } + if (!(lineWidth >= 0.01 && lineWidth < 100)) { + return ondone("invalid nozzle size"); + } + + const sliceMinHeight = process.sliceAdaptive && process.sliceMinHeight > 0 ? + Math.min(process.sliceMinHeight, sliceHeight) : 0; + + if (sliceHeightBase <= 0) { + console.log("invalid first layer height < slice height"); + console.log("reverting to min valid slice height"); + sliceHeightBase = sliceMinHeight || sliceHeight; + } + + let bounds = widget.getBoundingBox(); + let points = widget.getPoints(); + let indices = []; + let heights = []; + let healed = false; + let slices; // set by decodeSlices() + + // handle z cutting (floor method) and base flattening + let zPress = isBelt ? process.firstLayerFlatten || 0 : 0; + let zCut = widget.track.zcut || 0; + let { belt } = widget; + if (zCut || zPress) { + for (let p of points) { + if (!p._z) { + p._z = p.z; + if (zPress) { + if (isBelt) { + let zd = (belt.slope * p.z) - p.y; + if (zd > 0 && zd <= zPress) { + p.y += zd * belt.cosf; + p.z -= zd * belt.sinf; + } + } else { + if (p.z <= zPress) p.z = 0; + } + } + if (zCut && !isBelt) { + p.z -= zCut; + } + } + } + } + + // create Slice objects for specified list of Z heights + // zGen() produces the list (or empty for slicer auto-detected) + slice(points, { + strict: true, + debug: process.xray, + xray: process.xray, + zMin: bounds.min.z, + zMax: bounds.max.z - zCut, + union: controller.healMesh, + indices: process.indices || process.xray, + useAssembly, + post: 'FDM', + post_args: { + compInner: sliceCompInner, + compOuter: sliceCompOuter, + shellOffset, + fillOffset, // distance: top inset for sparse/solid infill + clipOffset, // distance: create simpler clip offset for supports + lineWidth, + vaseMode, + process, + }, + zGen, + // slicer function (worker local or minion distributed) + slicer(z, points, opts) { + return (isConcurrent ? minions.sliceZ : sliceZ)(z, points, opts); + }, + onupdate(update) { + return onupdate(0.0 + update * 0.5) + } + }) + .then(decodeSlices) + .then(processSlices) + .then(ondone); + + // z index generator (bottom up) + function zGen(zopt) { + if (process.xray) { + return zopt.zIndexes; + } + let { zMin, zMax } = zopt; + let h1 = sliceHeight; + let h0 = Math.abs(zMin) < 0.0001 ? (sliceHeightBase || h1) : h1; + let hm = sliceMinHeight || 0; + let h = h0; + let z = h0; + let zi = indices; // indices + let zh = heights; // heights + if (hm) { + // adaptive increments based on z indices (var map to legacy code) + let zIncFirst = h0; + let zInc = h1; + let zIncMin = hm; + let zHeights = heights; + let zIndexes = indices; + let zOrdered = Object.values(zopt.zIndexes).map(v => parseFloat(v)); + // console.log('adaptive slicing', zIncMin, ':', zInc, 'from', zMin, 'to', zMax); + let zPos = zIncFirst, + zOI = 0, + zDelta, + zDivMin, + zDivMax, + zStep, + nextZ, + lzp = zPos; + // adaptive slice height + // first slice/height is fixed from base + zHeights.push(zIncFirst); + zIndexes.push(zIncFirst); + // console.log({zIncFirst, zOrdered}) + while (zPos < zMax && zOI < zOrdered.length) { + nextZ = zOrdered[zOI++]; + if (zPos >= nextZ) { + // console.log('skip',{zPos},'>=',{nextZ}); + continue; + } + zDelta = nextZ - zPos; + if (zDelta < zIncMin) { + // console.log('skip',{zDelta},'<',{zIncMin}); + continue; + } + zDivMin = Math.floor(zDelta / zIncMin); + zDivMax = Math.floor(zDelta / zInc); + if (zDivMax && zDivMax <= zDivMin) { + if (zDelta % zInc > CONSTANTS.ADAPTIVE_SLICE_TOLERANCE) zDivMax++; + zStep = zDelta / zDivMax; + // console.log(`--- zDivMax <= zDivMin ---`, zStep, zDelta % zInc) + } else { + zStep = zDelta; + } + // console.log({nextZ, zPos, zDelta, zStep, zDivMin, zDivMax}) + while (zPos < nextZ) { + zHeights.push(zStep); + zIndexes.push(zPos + zStep); + zPos += zStep; + // console.log({D: zPos - lzp, zPos}) + // lzp = zPos; + } + } + // console.log({zIndexes, zHeights}); + } else { + // simple based + fixed increment + while (true) { + // reduce slice position by half layer height + let realz = (z - (h / 2)).round(3); + if (realz > zMax) { + break; + } + zh.push(h); + zi.push(realz); + h = h1; + z += h; + } + } + return zi; + } + + // turn slicer raw output into Slice objects + // z index order should be bottom up at this point + async function decodeSlices(output) { + // post process slices and re-incorporate missing meta-data + slices = output.slices.map(data => { + let { z, clip, lines, groups, changes } = data; + if (!data.tops) return null; + let slice = newSlice(z).addTops(data.tops, { minArea: CONSTANTS.MIN_POLY_AREA }); + slice.index = indices.indexOf(z); + slice.height = heights[slice.index]; + slice.clips = clip; + // do not warn on merging supports + if (changes) { + healed = true; + slice.changes = changes; + if (devel) { + console.log('slice healed', slice.index, slice.z, changes); + } + } + if (process.xray) { + slice.index = process.xray.shift(); + slice.lines = lines; + slice.groups = groups; + slice.xray = slice.index; + } + return slice; + }).filter(s => s); + } + + // calculate % complete and call onupdate() + function doupdate(index, from, to, msg) { + trackupdate(index / slices.length, from, to, msg); + } + + // slicing is the first 50% of the update "time" + function trackupdate(pct, from, to, msg) { + onupdate(0.5 + (from + (pct * (to - from))) * 0.5, msg); + } + + // for each slice, performe a function and call doupdate() + function forSlices(from, to, fn, msg) { + slices.forEach(slice => { + fn(slice); + doupdate(slice.index, from, to, msg) + }); + } + + /** + * Process automatic and manual shadow-based support generation + */ + async function processSupports() { + if (process.sliceSupportType === 'disabled') { + return; + } + + let stack = slices.slice(); + let indices = stack.map(s => s.z); + let zAngNorm = Math.sin(process.sliceSupportAngle * Math.PI / 180); + let manual = process.sliceSupportType === 'manual'; + + // sort bottom up so shadows do not accumulate + // since that is done later and clipped to slice.clips + stack.sort((a,b) => a.z - b.z); + + // process manual supports if they exist + let { paint } = widget.anno; + let { belt } = widget; + // apply belt transformations, if needed + if (belt && paint) { + let { anchor, angle, dy, slope } = belt; + // make a copy we can modify + paint = structuredClone(paint); + for (let rec of paint) { + let { point } = rec; + // convert yz plane to xy plane to re-use point utility function + let np = newPoint(point.y + dy, point.z, 0).rotate(angle * (Math.PI/180)); + point.y = np.x; + point.z = np.y; + if (anchor) { + point.y += anchor.len; + point.z += anchor.len; + } + } + } + // convert paint points to circles on matching slices + if (manual && paint?.length) { + let hpi = Math.PI/2; + for (let slice of stack) { + let polys = []; + for (let rec of paint) { + let { point, radius } = rec; + let dz = Math.abs(slice.z - point.z); + if (dz < radius) { + // scale radius by distance from point.z + radius = (Math.acos(dz/radius) / hpi) * radius; + polys.push(newPolygon().centerCircle(point, radius, 10)); + } + } + slice.shadow = POLY.union(polys, 0, true); + } + } + + // create automatic shadows supports when on manual paint + if (!manual) { + await widget.computeShadowStack(indices, progress => { + trackupdate(progress, 0.05, 0.10, "shadow"); + }, zAngNorm); + + for (let slice of stack) { + slice.shadow = await widget.shadowAt(slice.z, true); + } + } + + // 1. accumulate / union shadow coverage top down + // 2. trim to area outside slice.clips + let minArea = lineWidth; + let shadowSum; + let length = stack.length; + let count = 0; + + // perform accumulation top down + for (let slice of stack.reverse()) { + let shadow = slice.shadow ?? []; + if (process.sliceSupportExtra) { + shadow = POLY.offset(shadow, process.sliceSupportExtra); + } + // shadows accumulate down + if (shadowSum) { + shadow = POLY.union([...shadow, ...shadowSum], minArea, true); + } + // subtract slice.clips areas (widget boundaries) from shadow projection + if (true) { + let rem = []; + let clips = [ + slice.up?.clips, + slice.clips, + slice.down?.clips + ].filter(v => v).flat(); + clips = POLY.union(clips, minArea, true); + POLY.subtract(shadow, clips, rem, null, slice.z, minArea, { wasm: false }); + shadow = rem; + } + // pump shadow to clean to clean it up + if (true) { + let bump = lineWidth * 2; + shadow = POLY.offset(shadow, bump, { z: slice.z }); + shadow = POLY.offset(shadow, -bump, { z: slice.z }); + } + shadowSum = shadow; + // if belt, clip shadow to belt angle projection along platform + if (belt) { + let boundsx = bounds.dim.x * 1.1; + let boundsy = bounds.dim.y * 1.1; + let skewy = slice.z * belt.slope; + let clip = newPolygon() + .centerRectangle(newPoint(0, 0, slice.z), boundsx, boundsy) + .move({ x: 0, y: -boundsy / 2 + skewy, z: 0 }); + shadow = POLY.trimTo(shadow, [ clip ]); + if (devel) slice.output().setLayer("belt clip", 0xffff00).addPolys([ clip ]); + } + slice.supports = shadow; + if (devel) slice.output().setLayer("shadow", 0xff0000).addPolys(shadow); + trackupdate((++count/length), 0.10, 0.15, "support"); + } + } + + /** + * Process top and bottom layers or any other + * layers detected and marked for solid fill + */ + async function processSolidLayers() { + forSlices(0.15, 0.2, slice => { + let range = slice.params; + let isBottom = slice.index < bottomLayers; + let isTop = topLayers && slice.index > slices.length - topLayers - 1; + let isDense = range.sliceFillSparse > CONSTANTS.DENSE_INFILL_THRESHOLD; + let isSolid = (isBottom || ((isTop || isDense) && !vaseMode)); + let solidWidth = isSolid ? range.sliceFillWidth || 1 : 0; + if (solidWidth) { + let fillSpace = fillSpacing * solidWidth; + layerMakeSolid({ slice, spacing: fillSpace, angle: sliceFillAngle }); + } + if (slice.index === slices.length - 1) { + slice.isFlatsLayer = true; + } + sliceFillAngle = (sliceFillAngle + 90.0) % 360; + }, "solid layers"); + } + + /** + * Process layer diffs and project solid areas + */ + async function processLayerDiffs() { + // boolean diff layers to detect bridges and flats + profileStart("delta"); + forSlices(0.2, 0.33, slice => { + let params = slice.params || process; + let solidMinArea = params.sliceSolidMinArea; + let sliceMinThick = params.sliceSolidMinThick; + let sliceFillGrow = params.sliceFillGrow; + layerDiff(slice, { area: solidMinArea, grow: sliceFillGrow, thick: sliceMinThick }); + }, "layer deltas"); + profileEnd(); + // project bridges and flats up and down into part + profileStart("delta-project"); + forSlices(0.33, 0.34, slice => { + let params = slice.params || process; + topLayers = params.sliceTopLayers || 0; + bottomLayers = params.sliceBottomLayers || 0; + if (topLayers) projectFlats(slice, topLayers); + if (bottomLayers) projectBridges(slice, bottomLayers); + if (slice.flats) POLY.setZ(slice.flats, slice.z); + }, "layer deltas"); + profileEnd(); + // union solid areas + profileStart("solid-union"); + forSlices(0.34, 0.35, slice => { + if (slice.solids) { + slice.solids = POLY.union(slice.solids, 0, true); + } + }); + profileEnd(); + } + + /** + * Process solid fill patterns + */ + async function processSolidFills() { + profileStart("solid-fill") + let promises = isConcurrent ? [] : undefined; + forSlices(0.35, promises ? 0.4 : 0.5, slice => { + let params = slice.params || process; + let solidWidth = params.sliceFillWidth || 1; + let fillSpace = fillSpacing * solidWidth; + let solidMinArea = params.sliceSolidMinArea; + layerFillSolids({ slice, spacing: fillSpace, angle: sliceFillAngle, minArea: solidMinArea, promises }); + sliceFillAngle = (sliceFillAngle + 90.0) % 360; + }, "fill solids"); + // very last layer (top) is set to finish solid rate + slices.last().finishSolids = true + if (promises) { + await tracker(promises, (i, t) => { + trackupdate(i / t, 0.4, 0.5); + }); + } + profileEnd(); + } + + /** + * Process sparse infill patterns + */ + async function processSparseInfill() { + let lastType; + let promises = isConcurrent ? [] : undefined; + forSlices(0.5, promises ? 0.55 : 0.7, slice => { + let params = slice.params || process; + if (!params.sliceFillSparse) { + return; + } + let newType = params.sliceFillType; + layerSparseFill(slice, { + settings, + process, + device, + lineWidth, + spacing: fillOffset, + density: params.sliceFillSparse, + bounds: widget.getBoundingBox(), + height: sliceHeight, + type: newType, + cache: params._range !== true && lastType === newType, + promises + }); + lastType = newType; + }, "infill"); + if (promises) { + await tracker(promises, (i, t) => { + trackupdate(i / t, 0.55, 0.7); + }); + } + // filter out tiny fill points less than nozzle diameter + // if (false) + for (let slice of slices) { + for (let top of slice.tops) { + if (top.fill_sparse) + top.fill_sparse = top.fill_sparse.filter(p => p.perimeter() >= lineWidth); + } + } + // back-fill slices marked for infill cloning + for (let slice of slices) { + if (slice._clone_sparse) { + let tops = slice.tops; + let down = slice.down.tops; + for (let i=0; i p.cloneZ(slice.z)); + } + } + } + } + + /** + * Process support structure fills + */ + async function processSupportFills() { + profileStart("support-fill"); + let promises = false && isConcurrent ? [] : undefined; + forSlices(0.8, promises ? 0.88 : 0.9, slice => { + let params = slice.params || process; + let density = params.sliceSupportDensity; + layerSupportFill({ + angle: process.sliceSupportFill, + density, + isBelt, + lineWidth, + outline: process.sliceSupportOutline !== false, + promises, + slice, + }); + }, "support fill"); + if (promises) { + await tracker(promises, (i, t) => { + trackupdate(i / t, 0.88, 0.9); + }); + } + profileEnd(); + } + + /** + * Process brick/interleave mode slicing + */ + async function processBrickMode() { + let indices = slices.map(s => s.index); + let first = indices[1]; + let last = indices[indices.length - 2]; + let nu = []; + for (let slice of slices) { + if (slice.index < first || slice.index > last) { + continue; + } + let nuSlice = slice.clone(); + nuSlice.z -= slice.height / 2; + if (slice.index === first) { + nuSlice.z = slice.z - slice.height / 4; + nuSlice.height = slice.height / 2; + } else { + nuSlice.height = slice.height; + } + nu.push(nuSlice); + let ti = 0; + for (let top of slice.tops || []) { + let nuTop = nuSlice.tops[ti++]; + nuTop.shells = []; + top.shells = top.shells.filter((s,i) => { + if (i % 2 === 0) { + return true; + } else { + nuTop.shells.push(s); + return false; + } + }); + } + if (slice.index === last) { + let cap = nuSlice.clone(); + cap.z += (slice.height * 0.75); + cap.height = (slice.height / 2); + nu.push(cap); + cap.tops?.forEach((top, i) => { + top.shells = nuSlice.tops[i].shells.clone(); + }); + } + } + slices.appendAll(nu); + slices.sort((a,b) => a.z - b.z); + slices.forEach((s,i) => s.index = i); + } + + async function renderSlices() { + forSlices(0.9, 1.0, slice => { + let params = slice.params || process; + layerRender(slice, params, { + dark: controller.dark, + devel: controller.devel, + renderContext + }); + }, "render"); + } + + async function processSlices() { + // alert non-manifold parts + if (healed) { + onupdate(null, null, "part may not be manifold"); + } + + // reverse slices to perform these ops top-down + // remove all empty slices above part but leave below + // for multi-part (multi-extruder) setups where the void is ok + let found = false; + slices = slices.reverse().filter(slice => { + if (slice.tops.length) { + return found = true; + } else { + return found; + } + }).reverse(); + + // inject one empty slice at the top so that + // top layer flats are detected properly + slices.push(newSlice(slices.peek().index + 1)); + + // attach slices to widget since the + // variable will not be replaced after this + widget.slices = slices; + + // exit if no slices detected with tops + if (!slices || slices.length === 0) { + return; + } + + // connect slices into linked list for island/bridge projections + for (let i=1; i 0 + if (!vaseMode) { + await processLayerDiffs(); + await processSolidFills(); + await processSparseInfill(); + } + + // fill all supports (auto and manual) + if (supportDensity) { + await processSupportFills(); + } + + // brick/interleave mode processing + if (isBrick) { + await processBrickMode(); + } + + // render if not explicitly disabled + if (render) { + await renderSlices(); + } + + if (isBelt) { + finalizeBeltBounds({ slices, widget }); + } + } +} + +// replace with tip2tipEmit? +function connect_lines(lines, maxd = Infinity) { + const newlines = []; + let op2; + let eo = 0; + for (let i=0; i a.map(p => newPolygon().centerCircle(p, p.r, 12).setZ(slice.z))).flat(), vopt({ offset, height }, ctx)); + + if (!isThin && top.thin_wall?.length) output + .setLayer("walls", Color.thin) + .addPolys(top.thin_wall.map(a => + a.length === 1 ? + newPolygon().centerCircle(a[0], a[0].r/2, 12) : + newPolygon(a.map(p => newPoint(p.x, p.y))) + .setZ(slice.z) + .closeIf(height) + ), vopt({ offset, height }, ctx)); + + if (top.fill_lines?.length) output + .setLayer("solid fill", Color.fill) + .addLines(top.fill_lines || [], vopt({ offset: offset * solidWidth, height, z:slice.z }, ctx)); + + if (!(slice.belt?.anchor)) output + .setLayer("sparse fill", Color.infill) + .addPolys(top.fill_sparse || [], vopt({ offset, height, outline: true, trace:true }, ctx)) + + if (slice.belt?.anchor) output + .setLayer("anchor", Color.anchor) + .addPolys(top.fill_sparse || [], vopt({ offset, height, outline: true, trace:true }, ctx)) + + if (top.thin_fill?.length) output + .setLayer("thin fill", Color.thin) + .addLines(top.thin_fill, vopt({ offset, height }, ctx)); + + if (top.gaps?.length && devel) output + .setLayer("gaps", Color.gaps) + .addPolys(top.gaps, vopt({ offset, height, thin: true }, ctx)); + + if (isThin && devel && top.fill_off?.length) { + slice.output() + .setLayer('fill inset', Color.inset) + .addPolys(top.fill_off) + .setLayer('last', { face: 0, line: 0x008888, check: 0x008888 }) + .addPolys(top.last); + } + } + + if (isThin && devel) { + if (slice.solids?.length) output + .setLayer("solids", { face: 0xbbbb00, check: 0xbbbb00 }) + .addAreas(slice.solids); + + if (slice.bridges?.length) output + .setLayer("bridges", { face: 0x00cccc, line: 0x00cccc, check: 0x00cccc }) + .addAreas(slice.bridges); + + if (slice.flats?.length) output + .setLayer("flats", { face: 0xaa00aa, line: 0xaa00aa, check: 0xaa00aa }) + .addAreas(slice.flats); + } + + if (slice.supports && params.sliceSupportOutline) output + .setLayer("support", Color.support) + .addPolys(slice.supports, vopt({ offset, height }, ctx)); + + if (slice.supports) slice.supports.forEach(poly => { + if (poly.fill) output + .setLayer("support", Color.support) + .addLines(poly.fill, vopt({ offset, height }, ctx)); + }); + + if (slice.xray) { + const color = [ 0xff0000, 0x00aa00, 0x0000ff, 0xaaaa00, 0xff00ff, 0x0 ]; + if (slice.lines) { + slice.lines.forEach((line, i) => { + const group = i % 5; + slice.output().setLayer(`l${group}`, color[group]).addLine(line.p1, line.p2); + }); + } + if (slice.groups) + POLY.nest(slice.groups).forEach((poly, i) => { + const group = i % 5; + slice.addTop(poly); + // slice.output().setLayer(`g${i}`, 0x888888).addPoly(poly); + slice.output().setLayer(`g${i}`, color[group]).addPoly(poly); + }); + } + + // console.log(slice.index, slice.render.stats); +} + +/** + * DRIVER SLICE CONTRACT + * + * global slice operation run after all individual widget slice() complete + */ +export function slicePost(settings, onupdate) { + // future home of brim and anchor generation + let widgets = Object.values(self.kiri_worker.cache) + .filter(w => !w.meta.disabled) + .sort((a,b) => { + return a.slices[0].z - b.slices[0].z + }); + + // assign grid_id which can be embedded in gcode and + // used by the controller to cancel objects during print + let { bounds } = settings; + for (let widget of widgets) { + let { pos, box } = widget.track; + // calculate top/left coordinate for widget + // relative to bounding box for all widgets + let tl = { + x: Math.round((pos.x - box.w/2 - bounds.min.x) / 10) + 1, + y: Math.round((pos.y - box.h/2 - bounds.min.y) / 10) + 1 + }; + widget.track.grid_id = tl.x * 100 + tl.y; + } + + // count extruders used + let ext = []; + for (let w of widgets) { + if (w.anno && w.anno.extruder >= 0) { + let e = w.anno.extruder; + if (ext.indexOf(e) < 0) { + ext.push(e); + } + } + } + + // sort widgets by first slice Z + widgets.sort((a,b) => { + return a.slices[0].z - b.slices[0].z; + }); + + // remove anchor slices from other widgets (only with multi-material) + if (ext.length > 1) { + // give first widget a pass since it should have the anchor + for (let w of widgets.slice(1)) { + w.slices = w.slices.filter(s => s.index >= 0); + } + } +} + +/** + * Compute offset shell polygons. For FDM, the first offset is usually half + * of the nozzle width. Each subsequent offset is a full nozzle width. User + * parameters control tweaks to these numbers to allow for better shell bonding. + * The last shell generated is a "fillOffset" shell. Fill lines are clipped to + * this polygon. Adjusting fillOffset controls bonding of infill to the shells. + * + * Most of this is done in slicePost() in FDM mode. now this is used by SLA, Laser + * + * @param {number} count + * @param {number} offsetN + * @param {number} fillOffset + * @param {Obejct} options + */ +export function layerProcessTops(slice, count, offset1, offsetN, fillOffset, opt = {}) { + for (let top of slice.tops) { + layerProcessTop(slice.z, top, count, offset1, offsetN, fillOffset, opt); + } +} + +/** + * Create an entirely solid layer by filling all top polygons + * with an alternating pattern. + * + * @param {Object} args - Options object + * @param {Slice} args.slice - Slice to fill + * @param {number} args.spacing - Fill line spacing in mm + * @param {number} args.angle - Fill angle in degrees + */ +function layerMakeSolid({ slice, spacing, angle }) { + if (slice.xray) { + return; + } + + if (slice.tops.length === 0 || typeof(angle) != 'number') { + slice.isSolidLayer = false; + return; + } + + slice.tops.forEach(function(top) { + if (!top.fill_off) return; // missing for inner brick layers + let lines = POLY.fillArea(top.fill_off, angle, spacing, null); + top.fill_lines.appendAll(lines); + }); + + slice.isSolidLayer = true; +}; + +/** + * Take output from pluggable sparse infill algorithm and clip to + * the bounds of the top polygons and their inner solid areas. + */ +function layerSparseFill(slice, options = {}) { + if (slice.xray || options.type === 'none') { + return; + } + + let process = options.process, + spacing = options.spacing, // spacing space between fill lines + density = options.density, // density of infill 0.0 - 1.0 + bounds = options.bounds, // bounding box of widget + height = options.height, // z layer height + cache = !(options.cache === false), + type = options.type || 'hex'; + + if (slice.tops.length === 0 || density === 0.0 || slice.isSolidLayer || slice.index < 0) { + slice.isSparseFill = false; + return; + } + + let tops = slice.tops, + down = slice.down, + clib = self.ClipperLib, + ctyp = clib.ClipType, + ptyp = clib.PolyType, + cfil = clib.PolyFillType, + clip = new clib.Clipper(), + ctre = new clib.PolyTree(), + poly, + polys = [], + lines = [], + line = [], + solids = [], + // callback passed to pluggable infill algorithm + target = { + // slice and slice property access + slice: function() { return slice }, + zIndex: function() { return slice.index }, + zValue: function() { return slice.z }, + // various option map access + options: function() { return options }, + lineWidth: function() { return options.lineWidth }, + bounds: function() { return bounds }, + zHeight: function() { return height }, + offset: function() { return spacing }, + density: function() { return density }, + repeat: function() { return process.sliceFillRepeat }, + // output functions + emit: function(x,y) { + if (isNaN(x)) { + solids.push(x); + } else { + line.push(newPoint(x, y, slice.z)); + slice.isSparseFill = true; + } + }, + newline: function() { + if (line.length > 0) { + lines.push(line); + line = []; + } + } + }; + + // use specified fill type + if (type && fill[type]) { + fill[type](target); + } else { + console.log({missing_infill: type}); + return; + } + + // force emit of last line + target.newline(); + + // prepare top infill structure + for (let top of tops) { + top.fill_sparse = top.fill_sparse || []; + polys.appendAll(top.fill_off); + polys.appendAll(top.solids); + } + + // update fill fingerprint for this slice + slice._fill_finger = POLY.fingerprint(polys); + + let skippable = cache && fill_fixed[type] ? true : false; + let miss = false; + // if the layer below has the same fingerprint, + // we may be able to clone the infill instead of regenerating it + if (skippable && slice.fingerprintSame(down)) { + // the fill fingerprint can slightly different because of solid projections + if (down._fill_finger && POLY.fingerprintCompare(slice._fill_finger, down._fill_finger)) { + for (let i=0; i { + top.fill_lines.push(p1, p2); + }); + } + } + if (fillable.length) { + let lines = POLY.fillArea(fillable, angl, options.lineWidth); + top.fill_lines.appendAll(lines); + } + } + } + } + + // if only solids were added and no lines to clip + if (!sparse_clip) { + return; + } + + if (options.promises) { + options.promises.push(self.kiri_worker.minions.clip(slice, polys, lines)); + return; + } + + lines = lines.map(a => a.map(p => p.toClipper())); + clip.AddPaths(lines, ptyp.ptSubject, false); + clip.AddPaths(POLY.toClipper(polys), ptyp.ptClip, true); + + if (clip.Execute(ctyp.ctIntersection, ctre, cfil.pftNonZero, cfil.pftEvenOdd)) { + for (let node of ctre.m_AllPolys) { + poly = POLY.fromClipperNode(node, slice.z); + for (let top of tops) { + // use only polygons inside this top + if (poly.isInside(top.poly)) { + top.fill_sparse.push(poly); + } + } + } + } +}; + +/** + * Find difference between fill inset poly on two adjacent layers. + * Used to calculate bridges, flats and then solid projections. + * 'expand' is used for top offsets in SLA mode + */ +export function layerDiff(slice, options = {}) { + const { sla, grow, area, thick } = options; + + if (slice.index < 0 || slice.xray) { + return; + } + + let boundary = !(slice.up && slice.down), + top = slice, + down = slice.down || newSlice(-1), + topInner = sla ? top.topPolys() : top.topInners(), + downInner = sla ? down.topPolys() : down.topInners(), + bridges = top.bridges = [], + flats = down.flats = []; + + // skip diffing layers that are identical + if (!boundary && slice.fingerprintSame(down)) { + top.bridges = bridges; + down.flats = flats; + // console.log(slice.z, 'layer fingerprint match'); + return; + } + + let newBridges = []; + let newFlats = []; + + POLY.subtract(topInner, downInner, newBridges, newFlats, slice.z, area, { + wasm: true + }); + + // console.log(slice.z, { newBridges, newFlats }); + newBridges = newBridges.filter(p => p.areaDeep() >= area && p.thickness(true) >= thick); + newFlats = newFlats.filter(p => p.areaDeep() >= area && p.thickness(true) >= thick); + + if (grow > 0 && newBridges.length) { + newBridges = POLY.offset(newBridges, grow); + } + if (grow > 0 && newFlats.length) { + newFlats = POLY.offset(newFlats, grow); + } + + bridges.appendAll(newBridges); + flats.appendAll(newFlats); +} + +/** + * Fill projected solid areas and store line data + * + * @param {Object} args - Options object + * @param {Slice} args.slice - Slice to process + * @param {number} args.spacing - Fill line spacing in mm + * @param {number} args.angle - Fill angle in degrees + * @param {number} [args.minArea] - Minimum polygon area in mm² (default: 1) + * @param {Promise[]} [args.promises] - Array for async operations + * @return {boolean} true if filled, false if not + */ +function layerFillSolids({ slice, spacing, angle, minArea, promises: fillQ }) { + let minarea = minArea || 1, + tops = slice.tops, + solids = slice.solids; + + if (!(tops && solids)) { + return; + } + + if (slice.isSolidLayer || slice.xray) { + return; + } + + let unioned = POLY.union(solids, undefined, true, { wasm: true }).flat(), + isSLA = (spacing === undefined && angle === undefined); + + if (solids.length === 0) return false; + if (unioned.length === 0) return false; + + let trims = [], + inner = isSLA ? slice.topPolys() : slice.topFillOff(); + + // trim each solid to the inner bounds + for (let p of unioned) { + p.setZ(slice.z); + for (let i of inner) { + let masks = p.mask(i); + if (masks && masks.length > 0) { + trims.appendAll(masks); + } + } + } + + // clear old solids and make array for new + tops.forEach(top => { top.solids = [] }); + + // replace solids with merged and trimmed solids + slice.solids = solids = trims; + + // parent each solid polygon inside the smallest bounding top + let make_solid_layer = false; + let tops_area = tops.length ? tops.map(top => top.poly.areaDeep()).reduce((a,i) => a+i) : 0; + for (let solid of solids) { + for (let top of tops) { + let stop = []; + if (top.poly.overlaps(solid)) { + if (!solid.parent || solid.parent.area() > top.poly.area()) { + if (solid.areaDeep() < minarea) { + // console.log({i:slice.index,cull_solid:solid,area:solid.areaDeep()}); + continue; + } + solid.parent = top.poly; + top.solids.push(solid); + stop.push(solid); + } + } + // problematic for organic shapes with lots of big and small tops + // the small tops tend to trigger entire layer fills. for now just + // trip full solid layer if a single top area diff > 50% + if (false && stop.length) { + let top_area = top.poly.areaDeep(); + let stop_area = stop.map(p => p.areaDeep()).reduce((a,v) => a + v); + // if the solid area > threshold of the top area, make entire layer solid + if (stop_area / tops_area > CONSTANTS.SOLID_LAYER_COVERAGE_THRESHOLD) { + make_solid_layer = true; + } + } + } + } + // if 50% of top is filled with solids, trigger layer conversion to solid + // in future, this should be limited to a specific top, not entire layer + if (make_solid_layer) { + for (let top of tops) { + top.solids = []; + } + layerMakeSolid({ slice, spacing, angle }); + return; + } + + // for SLA to bypass line infill + if (isSLA) { + return true; + } + + // create empty filled line array for each top + for (let top of tops) { + // synth belt anchor tops don't want fill + if (!top.fill_lines) { + continue; + } + const tofill = []; + const angfill = []; + const newfill = top.fill_lines = []; + // determine fill orientation from top + for (let solid of solids) { + if (solid.parent === top.poly) { + if (solid.fillang) { + angfill.push(solid); + } else { + tofill.push(solid); + } + } + } + if (tofill.length > 0) { + areaFill({ promises: fillQ, polys: tofill, angle, spacing, output: newfill }); + } + if (angfill.length > 0) { + top.fill_lines_ang = {spacing:spacing,list:[],poly:[]}; + for (let af of angfill) { + areaFill({ + promises: fillQ, + polys: [af], + angle: af.fillang.angle + CONSTANTS.ANGLED_FILL_ROTATION, + spacing, + output: newfill + }); + } + } + } +} + +/** + * Fill area with line pattern + * + * @param {Object} args - Options object + * @param {Promise[]} [args.promises] - Array for async operations + * @param {Polygon[]} args.polys - Polygons to fill + * @param {number} args.angle - Fill angle in degrees + * @param {number} args.spacing - Fill line spacing in mm + * @param {Array} args.output - Output array for fill lines + * @param {number} [args.minLen] - Minimum line length + * @param {number} [args.maxLen] - Maximum line length + */ +function areaFill({ promises: fillQ, polys, angle, spacing, output, minLen, maxLen }) { + if (fillQ) { + fillQ.push(self.kiri_worker.minions.fill(polys, angle, spacing, output, minLen, maxLen)); + } else { + POLY.fillArea(polys, angle, spacing, output, minLen, maxLen); + } +} + +/** + * Generate support structure fill patterns for a slice + * + * @param {SupportFillOptions} args - Configuration object + * @param {Promise[]} [args.promises] - Array for async fill operations (concurrent mode) + * @param {Slice} args.slice - Current slice to process + * @param {number} args.lineWidth - Extrusion width in mm (typically nozzle diameter) + * @param {number} args.density - Fill density from 0.0 (0%) to 1.0 (100%) + * Typical support density is 0.10-0.25 (10-25%) + * @param {boolean} args.isBelt - True for belt printer mode (affects auto-angle calculation) + * @param {number} [args.angle] - Fill angle in degrees. Special values: + * - undefined/null: Auto-calculate based on polygon shape + * - >= 1000: Auto-calculate (1000=0° for tall, 1090=90° for wide) + * - 0-360: Use specified angle + * @param {boolean} args.outline - Include support perimeter outline in output + * If false, only interior fill lines are generated + * + * @modifies {Slice} slice.supports - Updated with filled support polygons (array of Polygon) + * Each polygon has a .fill property with fill lines + * + * @see supportPolyFill - Called internally to generate fill patterns + * @see CONSTANTS.SUPPORT_INSET_RATIO - Inset ratio (1/3 of line width) + * @see CONSTANTS.SUPPORT_AUTO_ANGLE_WIDE - Auto angle for wide polygons (1090 = 90°) + * @see CONSTANTS.SUPPORT_AUTO_ANGLE_TALL - Auto angle for tall polygons (1000 = 0°) + * @see CONSTANTS.SUPPORT_CONNECT_DISTANCE_MULT - Distance multiplier for line connection (2x) + */ +function layerSupportFill({ promises, slice, lineWidth, density, isBelt, angle, outline, gap }) { + let polys = slice.supports; + if (polys) { + supportPolyFill({ + angle, + density, + isBelt, + lineWidth, + outline, + polys, + promises, + z: slice.z + }); + } +} + +/** + * Generate fill patterns for individual support polygons + * + * This is an internal helper function called by doSupportFill() that processes each support + * polygon individually to generate the hatching fill patterns. It handles: + * - Automatic fill angle calculation based on polygon aspect ratio + * - Intelligent inset to prevent perimeter over-extrusion + * - Optional line connection to reduce travel moves when no outline is needed + * + * @param {Object} args - Configuration object + * @param {Promise[]} [args.promises] - Array for async fill operations (concurrent mode) + * Pass same array through all calls for parallelism + * @param {Polygon[]} args.polys - Array of support polygons to fill + * Must have valid .bounds property for aspect ratio calculation + * @param {number} args.lineWidth - Extrusion width in mm (typically 0.4mm for 0.4mm nozzle) + * @param {number} args.density - Fill density from 0.0 (0%) to 1.0 (100%) + * Typical support values: 0.10-0.25 (10-25%) + * Higher density = stronger support but harder to remove + * @param {number} args.z - Current Z height in mm (for polygon operations) + * @param {boolean} args.isBelt - True for belt printer mode + * Forces use of SUPPORT_AUTO_ANGLE_WIDE (1090/90°) + * @param {number} [args.angle] - Fill angle in degrees. Special values: + * - undefined/null: Auto-calculate based on polygon aspect ratio + * - >= 1000: Auto-calculate (1000=0° for tall, 1090=90° for wide) + * - 0-360: Use specified angle + * @param {boolean} args.outline - Controls line connection behavior: + * - true: Keep individual fill lines (for support with perimeter) + * - false: Connect nearby lines to reduce travel moves + * + * @modifies {Polygon} Each poly in polys array gets a new .fill property containing: + * - Array of Polygon objects representing fill lines + * - Lines are either individual segments (outline=true) or + * connected paths (outline=false) for travel reduction + * + * @see layerSupportFill - Parent function that calls this after clipping/union + * @see areaFill - Lower-level function that generates the actual scan lines + * @see connect_lines - Utility that connects nearby line segments + * @see CONSTANTS.SUPPORT_AUTO_ANGLE_WIDE - Auto angle for wide polygons (1090 = 90°) + * @see CONSTANTS.SUPPORT_AUTO_ANGLE_TALL - Auto angle for tall polygons (1000 = 0°) + * @see CONSTANTS.SUPPORT_INSET_RATIO - Inset ratio for fill area (1/3) + * @see CONSTANTS.SUPPORT_CONNECT_DISTANCE_MULT - Distance multiplier for line connection (2x) + */ +function supportPolyFill({ promises, polys, lineWidth, density, z, isBelt, angle, outline }) { + // calculate fill density + let spacing = lineWidth * (1 / density); + for (let poly of polys) { + // calculate angle based on width/height ratio + let auto = isBelt || (poly.bounds.width() / poly.bounds.height() > 1) ? + CONSTANTS.SUPPORT_AUTO_ANGLE_WIDE : + CONSTANTS.SUPPORT_AUTO_ANGLE_TALL; + // inset support poly for fill lines + let inset = POLY.offset([poly], -lineWidth * CONSTANTS.SUPPORT_INSET_RATIO, {flat: true, z, wasm: true}); + // do the fill + if (inset && inset.length > 0) { + areaFill({ + angle: angle || auto, + output: poly.fill = [], + polys: inset, + promises, + spacing + }); + if (!outline && poly.fill.length) { + poly.fill = connect_lines(poly.fill, spacing * CONSTANTS.SUPPORT_CONNECT_DISTANCE_MULT); + } + } + } +} + +/** + * Project solid areas up or down through slices + * + * @param {Object} args - Options object + * @param {Slice} args.slice - Current slice + * @param {Polygon[]} args.polys - Polygons to project + * @param {number} args.count - Number of layers to project + * @param {boolean} args.up - Project upward (true) or downward (false) + * @param {boolean} args.first - First projection (hint fill angles) + */ +function projectSolid({ slice, polys, count, up, first }) { + if (slice && !up && count === 1) { + slice.isBridgeLayer = true; + } + + if (!slice || count <= 0) { + return; + } + + let clones = polys.clone(true); + if (first) { + for (let clone of clones) { + clone.hintFillAngle(); + } + } + + if (slice.solids) { + slice.solids.appendAll(clones); + } else if (clones && clones.length) { + console.log({no_solids_in: slice, for: clones}) + } + + if (count > 0) { + projectSolid({ + count: count - 1, + first: false, + polys, + slice: up ? slice.up : slice.down, + up + }); + } +} + +/** + * project bottom flats down into part + */ +export function projectFlats(slice, count, expand) { + if (!slice.down || !slice.flats) return; + // these flats are marked for finishing print speed + if (slice.flats?.length) { + slice.finishSolids = true; + slice.isFlatsLayer = true; + const polys = expand ? POLY.expand(slice.flats, expand) : slice.flats; + projectSolid({ slice, polys, count, up: false, first: true }); + } +} + +/** + * project top bridges up into part + */ +export function projectBridges(slice, count, expand) { + if (!slice.up || !slice.bridges) return; + // these bridges are marked for finishing print speed + if (slice.bridges?.length) { + slice.finishSolids = true; + slice.isBridgeLayer = true; + const polys = expand ? POLY.expand(slice.bridges, expand) : slice.bridges; + projectSolid({ slice, polys, count, up: true, first: true }); + } +} diff --git a/src/kiri/mode/laser/init-menu.js b/src/kiri/mode/laser/init-menu.js index 824fe6aa..5e5bac27 100644 --- a/src/kiri/mode/laser/init-menu.js +++ b/src/kiri/mode/laser/init-menu.js @@ -1,6 +1,6 @@ /** Copyright Stewart Allen -- All Rights Reserved */ -import { api } from '../../core/api.js'; +import { api } from '../../app/api.js'; let LANG = api.language.current; let { LASER, DRAG, WJET, WEDM } = api.const.MODES, diff --git a/src/kiri/mode/laser/client.js b/src/kiri/mode/laser/init-ui.js similarity index 100% rename from src/kiri/mode/laser/client.js rename to src/kiri/mode/laser/init-ui.js diff --git a/src/kiri/mode/laser/driver.js b/src/kiri/mode/laser/init-work.js similarity index 90% rename from src/kiri/mode/laser/driver.js rename to src/kiri/mode/laser/init-work.js index c07e4007..edcd186a 100644 --- a/src/kiri/mode/laser/driver.js +++ b/src/kiri/mode/laser/init-work.js @@ -1,17 +1,16 @@ /** Copyright Stewart Allen -- All Rights Reserved */ import { base } from '../../../geo/base.js'; -import { Packer } from '../../core/pack.js'; +import { Packer } from '../../app/pack.js'; import { newBounds } from '../../../geo/bounds.js'; import { newPoint } from '../../../geo/point.js'; import { newPrint } from '../../core/print.js'; -import { newSlopeFromAngle } from '../../../geo/slope.js'; import { polygons as POLY } from '../../../geo/polygons.js'; import { render } from '../../core/render.js'; import { slicer } from '../../../geo/slicer.js'; import { newSlice } from '../../core/slice.js'; import { newPolygon } from '../../../geo/polygon.js'; -import { STL } from '../../../load/stl.js'; +import { encode as encodeOBJ } from '../../../load/obj.js'; export const TYPE = { LASER: 0, @@ -20,6 +19,8 @@ export const TYPE = { WEDM: 3 }; +export const FUNC = {}; + function init(worker) { // console.log({ LASER_INIT: worker }); } @@ -98,76 +99,6 @@ function sliceEmitObjects(print, slice, groups, opt = { }) { return emit; }; -// convert arc into line segments -function arc(center, rad, s1, s2, out) { - let a1 = s1.angle; - let step = 5; - let diff = s1.angleDiff(s2, true); - let ticks = Math.abs(Math.floor(diff / step)); - let dir = Math.sign(diff); - let off = (diff % step) / 2; - if (off == 0) { - ticks++; - } else { - out.push( center.projectOnSlope(s1, rad) ); - } - while (ticks-- > 0) { - out.push( center.projectOnSlope(newSlopeFromAngle(a1 + off), rad) ); - a1 += step * dir; - } - out.push( center.projectOnSlope(s2, rad) ); -} - -// start to the "left" of the first point -function addKnifeRadii(poly, tipoff) { - poly.setClockwise(); - let oldpts = poly.points.slice(); - - // find leftpoint and make that the first point - let start = oldpts[0]; - let startI = 0; - let inner = tipoff < 0; - tipoff = Math.abs(tipoff); - for (let i=1; i start.x || (pt.x == start.x && pt.y < start.y))) { - start = pt; - startI = i; - } else if (!inner && (pt.x < start.x || (pt.x == start.x && pt.y > start.y))) { - start = pt; - startI = i; - } - } - if (startI > 0) { - oldpts = oldpts.slice(startI,oldpts.length).appendAll(oldpts.slice(0,startI)); - } - - let lastpt = oldpts[0].clone().move({x:-tipoff,y:0,z:0}); - let lastsl = lastpt.slopeTo(oldpts[0]).toUnit(); - let newpts = [ lastpt, lastpt = oldpts[0].clone() ]; - let tmp; - for (let i=1; i= 10) { - if (lastpt.distTo2D(nextpt) >= tipoff) { - arc(lastpt, tipoff, lastsl, nextsl, newpts); - } else { - // todo handle short segments - // newpts.push(lastpt.projectOnSlope(lastsl, tipoff) ); - // newpts.push( lastpt.projectOnSlope(nextsl, tipoff) ); - } - } - newpts.push(nextpt); - lastsl = nextsl; - lastpt = nextpt; - } - newpts.push( tmp = lastpt.projectOnSlope(lastsl, tipoff) ); - // newpts.push( tmp.clone().move({x:tipoff, y:0, z: 0}) ); - poly.open = true; - poly.points = newpts; -} - /** * DRIVER SLICE CONTRACT * @@ -191,6 +122,7 @@ function laser_slice(settings, widget, onupdate, ondone) { let indices = []; slicer.slice(points, { + strict: true, zMin: bounds.min.z, zMax: bounds.max.z, zGen(zopt) { @@ -287,7 +219,8 @@ async function laser_prepare(widgets, settings, update) { { ctOutLayer, ctOutTileSpacing, ctSliceSingle } = process, { ctOriginCenter, ctOriginBounds } = process, { ctOriginOffX, ctOriginOffY } = process, - { ctOutStack, ctOutMerged, ctOutKnifeTip, ctOutShaper } = process; + { ctOutStack, ctOutMerged, ctOutKnifeTip, ctOutShaper } = process, + { generateDragKnifePath } = FUNC; ctOutStack = ctOutStack && isLaser; print.widgets = widgets; @@ -365,7 +298,8 @@ async function laser_prepare(widgets, settings, update) { let gather = []; for (let poly of merged) { if (isKnife) { - addKnifeRadii(poly, ctOutKnifeTip); + poly = generateDragKnifePath(poly, ctOutKnifeTip); + // addKnifeRadii(poly, ctOutKnifeTip); } print.PPP(poly, gather, { extrude: poly.depth, @@ -377,12 +311,15 @@ async function laser_prepare(widgets, settings, update) { // output a layer for each slice if (isKnife) { for (let slice of widget.slices) { - for (let poly of slice.offset) { - addKnifeRadii(poly, ctOutKnifeTip); - if (poly.inner) poly.inner.forEach(ip => { - addKnifeRadii(ip, -ctOutKnifeTip); - }); - } + slice.offset = POLY + .flatten(slice.offset) + .map(poly => generateDragKnifePath(poly, ctOutKnifeTip)); + // for (let poly of slice.offset) { + // addKnifeRadii(poly, ctOutKnifeTip); + // if (poly.inner) poly.inner.forEach(ip => { + // addKnifeRadii(ip, -ctOutKnifeTip); + // }); + // } } } let lastEmit; @@ -610,7 +547,7 @@ function exportElements(settings, output, onpre, onpoly, onpost, onpoint, onlaye onpost(); }; -function exportSTL(settings, data) { +function exportOBJ(settings, data) { let { ctSliceHeight, ctOutSmooth } = settings.process; let polys = []; for (let array of data) { @@ -633,7 +570,7 @@ function exportSTL(settings, data) { }).flat().filter(v => v); } let layers = {}; - let mesh = []; + let obj = []; for (let poly of polys) { let z = poly.getZ(); let layer = layers[z]; @@ -644,13 +581,15 @@ function exportSTL(settings, data) { } for (let rec of Object.entries(layers)) { let [ z, polys ] = rec; + let layer = []; polys = POLY.nest(polys); for (let poly of polys) { let ext = poly.setZ(0).extrude(ctSliceHeight, { zadd: parseFloat(z) }); - mesh = mesh.concat(ext); + layer = layer.concat(ext); } + obj.push({ file:`z-${z}`, varr: layer }); } - return new STL().encode(mesh); + return encodeOBJ(obj); } /** @@ -933,5 +872,5 @@ export const LASER = { exportGCode, exportDXF, exportSVG, - exportSTL + exportOBJ }; diff --git a/src/kiri/mode/sla/init-menu.js b/src/kiri/mode/sla/app/init-menu.js similarity index 98% rename from src/kiri/mode/sla/init-menu.js rename to src/kiri/mode/sla/app/init-menu.js index fa45d671..6e3e1bb3 100644 --- a/src/kiri/mode/sla/init-menu.js +++ b/src/kiri/mode/sla/app/init-menu.js @@ -1,6 +1,6 @@ /** Copyright Stewart Allen -- All Rights Reserved */ -import { api } from '../../core/api.js'; +import { api } from '../../../app/api.js'; let LANG = api.language.current; let { SLA } = api.const.MODES, diff --git a/src/kiri/mode/sla/client.js b/src/kiri/mode/sla/app/init-ui.js similarity index 96% rename from src/kiri/mode/sla/client.js rename to src/kiri/mode/sla/app/init-ui.js index 0d1f849d..48a20bbd 100644 --- a/src/kiri/mode/sla/client.js +++ b/src/kiri/mode/sla/app/init-ui.js @@ -1,7 +1,7 @@ /** Copyright Stewart Allen -- All Rights Reserved */ -import { $ } from '../../../moto/webui.js'; -import { api } from '../../core/api.js'; +import { $ } from '../../../../moto/webui.js'; +import { api } from '../../../app/api.js'; export const SLA = { init, diff --git a/src/kiri/mode/sla/export.js b/src/kiri/mode/sla/work/export.js similarity index 99% rename from src/kiri/mode/sla/export.js rename to src/kiri/mode/sla/work/export.js index 82867ea4..d296aea7 100644 --- a/src/kiri/mode/sla/export.js +++ b/src/kiri/mode/sla/work/export.js @@ -2,7 +2,7 @@ import { CXDLP } from './x_cxdlp.js'; import { photon } from './x_photon.js'; -import { SLA } from './driver.js'; +import { SLA } from './init-work.js'; /** * DRIVER CONTRACT - runs in worker diff --git a/src/kiri/mode/sla/driver.js b/src/kiri/mode/sla/work/init-work.js similarity index 96% rename from src/kiri/mode/sla/driver.js rename to src/kiri/mode/sla/work/init-work.js index 1a160945..6994cc22 100644 --- a/src/kiri/mode/sla/driver.js +++ b/src/kiri/mode/sla/work/init-work.js @@ -1,6 +1,6 @@ /** Copyright Stewart Allen -- All Rights Reserved */ -import { newPrint } from '../../core/print.js'; +import { newPrint } from '../../../core/print.js'; import { sla_slice } from './slice.js'; import { sla_export } from './export.js'; diff --git a/src/kiri/mode/sla/slice.js b/src/kiri/mode/sla/work/slice.js similarity index 97% rename from src/kiri/mode/sla/slice.js rename to src/kiri/mode/sla/work/slice.js index 2c2e3d4e..2ec33eca 100644 --- a/src/kiri/mode/sla/slice.js +++ b/src/kiri/mode/sla/work/slice.js @@ -1,13 +1,13 @@ /** Copyright Stewart Allen -- All Rights Reserved */ -import { util } from '../../../geo/base.js'; -import { slicer } from '../../../geo/slicer.js'; -import { newPolygon } from '../../../geo/polygon.js'; -import { polygons as POLY } from '../../../geo/polygons.js'; -import { newSlice, newTop } from '../../core/slice.js'; -import { doShells, doDiff, projectFlats, projectBridges } from '../fdm/slice.js'; -import { PNG } from '../../../ext/pngjs.esm.js'; -import { SLA } from './driver.js'; +import { util } from '../../../../geo/base.js'; +import { slicer } from '../../../../geo/slicer.js'; +import { newPolygon } from '../../../../geo/polygon.js'; +import { polygons as POLY } from '../../../../geo/polygons.js'; +import { newSlice, newTop } from '../../../core/slice.js'; +import { layerProcessTops, layerDiff, projectFlats, projectBridges } from '../../fdm/work/slice.js'; +import { PNG } from '../../../../ext/pngjs.esm.js'; +import { SLA } from './init-work.js'; const tracker = util.pwait; @@ -149,14 +149,14 @@ export function sla_slice(settings, widget, onupdate, ondone) { work_remain = work_total; forSlices(slices, 5, (slice,index) => { if (process.slaShell) { - doShells(slice, 2, 0, process.slaShell); + layerProcessTops(slice, 2, 0, process.slaShell); } else { - doShells(slice, 1, 0); + layerProcessTops(slice, 1, 0); } }, "shells"); forSlices(slices, 10, (slice) => { if (slice.synth) return; - doDiff(slice, 0.000001, { + layerDiff(slice, 0.000001, { sla: true, fakedown: !process.slaOpenBase }); diff --git a/src/kiri/mode/sla/x_cxdlp.js b/src/kiri/mode/sla/work/x_cxdlp.js similarity index 100% rename from src/kiri/mode/sla/x_cxdlp.js rename to src/kiri/mode/sla/work/x_cxdlp.js diff --git a/src/kiri/mode/sla/x_photon.js b/src/kiri/mode/sla/work/x_photon.js similarity index 99% rename from src/kiri/mode/sla/x_photon.js rename to src/kiri/mode/sla/work/x_photon.js index 968bfa5c..67af34ea 100644 --- a/src/kiri/mode/sla/x_photon.js +++ b/src/kiri/mode/sla/work/x_photon.js @@ -1,6 +1,6 @@ /** Copyright Stewart Allen -- All Rights Reserved */ -import { SLA } from './driver.js'; +import { SLA } from './init-work.js'; function generatePhoton(print, conf, progress) { let printset = print.settings, diff --git a/src/kiri/mode/wedm/client.js b/src/kiri/mode/wedm/init-ui.js similarity index 98% rename from src/kiri/mode/wedm/client.js rename to src/kiri/mode/wedm/init-ui.js index cf5db035..871adec7 100644 --- a/src/kiri/mode/wedm/client.js +++ b/src/kiri/mode/wedm/init-ui.js @@ -1,6 +1,6 @@ /** Copyright Stewart Allen -- All Rights Reserved */ -import { api } from '../../core/api.js'; +import { api } from '../../app/api.js'; const state = { api: undefined, diff --git a/src/kiri/mode/wedm/driver.js b/src/kiri/mode/wedm/init-work.js similarity index 89% rename from src/kiri/mode/wedm/driver.js rename to src/kiri/mode/wedm/init-work.js index 3342abfd..18905591 100644 --- a/src/kiri/mode/wedm/driver.js +++ b/src/kiri/mode/wedm/init-work.js @@ -1,6 +1,6 @@ /** Copyright Stewart Allen -- All Rights Reserved */ -import { LASER, TYPE } from '../laser/driver.js'; +import { LASER, TYPE } from '../laser/init-work.js'; const DEG2RAD = Math.PI / 180; diff --git a/src/kiri/mode/wjet/client.js b/src/kiri/mode/wjet/init-ui.js similarity index 100% rename from src/kiri/mode/wjet/client.js rename to src/kiri/mode/wjet/init-ui.js diff --git a/src/kiri/mode/wjet/driver.js b/src/kiri/mode/wjet/init-work.js similarity index 82% rename from src/kiri/mode/wjet/driver.js rename to src/kiri/mode/wjet/init-work.js index b11c54fd..0c8ac31c 100644 --- a/src/kiri/mode/wjet/driver.js +++ b/src/kiri/mode/wjet/init-work.js @@ -1,6 +1,6 @@ /** Copyright Stewart Allen -- All Rights Reserved */ -import { LASER, TYPE } from '../laser/driver.js'; +import { LASER, TYPE } from '../laser/init-work.js'; function init(worker) { // console.log({ WATERJET_INIT: worker }); diff --git a/src/kiri/run/engine.js b/src/kiri/run/engine.js index 5b9d7008..c8d995cd 100644 --- a/src/kiri/run/engine.js +++ b/src/kiri/run/engine.js @@ -3,11 +3,11 @@ import '../../add/array.js'; import '../../add/class.js'; import '../../add/three.js'; -import { api } from '../core/api.js'; -import { conf } from '../core/conf.js'; -import { client } from '../core/client.js'; +import { api } from '../app/api.js'; +import { conf } from '../app/conf/defaults.js'; +import { client } from '../app/workers.js'; import { load } from '../../load/file.js'; -import { newWidget } from '../core/widget.js'; +import { newWidget } from '../app/widget.js'; class Engine { constructor({ workURL, poolURL } = {}) { @@ -123,6 +123,7 @@ class Engine { process.camStockX = stock.x; process.camStockY = stock.y; process.camStockZ = stock.z; + if (this.origin) settings.stock.center = origin; return this; } @@ -131,7 +132,8 @@ class Engine { } setOrigin(x, y, z) { - this.settings.origin = { x, y, z }; + this.origin = { x, y, z }; + if (this.settings.stock) this.settings.stock.center = { x, y, z }; return this; } diff --git a/src/kiri/run/frame.js b/src/kiri/run/frame.js index b363f2c1..61fdefeb 100644 --- a/src/kiri/run/frame.js +++ b/src/kiri/run/frame.js @@ -47,7 +47,7 @@ window.addEventListener('message', onMessage); }, - send: send, + send, load: (load) => { send({ load }) }, diff --git a/src/kiri/run/minion.js b/src/kiri/run/minion.js index d8436f87..ab6edb7f 100644 --- a/src/kiri/run/minion.js +++ b/src/kiri/run/minion.js @@ -8,14 +8,15 @@ import '../../add/three.js'; import { base } from '../../geo/base.js'; import { codec, encode, encodePointArray } from '../core/codec.js'; -import { doTopShells } from '../mode/fdm/post.js'; +import { layerProcessTop } from '../mode/fdm/work/post.js'; import { newPoint } from '../../geo/point.js'; +import { newWidget } from '../core/widget.js'; import { polygons as POLY } from '../../geo/polygons.js'; import { sliceZ, sliceConnect } from '../../geo/slicer.js'; -import { Slicer as cam_slicer } from '../mode/cam/slicer_cam.js'; -import { Slicer as topo_slicer } from '../mode/cam/slicer_topo.js'; -import { Probe, Trace, raster_slice } from '../mode/cam/topo3.js'; -import { Topo as Topo4, rotatePoints } from '../mode/cam/topo4.js'; +import { Slicer as cam_slicer } from '../mode/cam/work/slicer-cam.js'; +import { Slicer as topo_slicer } from '../mode/cam/work/slicer-topo.js'; +import { Probe, Trace, raster_slice } from '../mode/cam/work/topo3.js'; +import { Topo as Topo4, rotatePoints } from '../mode/cam/work/topo4.js'; import { wasm_ctrl } from '../../geo/wasm.js'; const clib = self.ClipperLib; @@ -91,7 +92,7 @@ const funcs = self.minion = { topShells(data, seq) { let top = codec.decode(data.top, {full: true}); let {z, count, offset1, offsetN, fillOffset, opt} = data; - doTopShells(z, top, count, offset1, offsetN, fillOffset, opt); + layerProcessTop(z, top, count, offset1, offsetN, fillOffset, opt); let state = { zeros: [] }; reply({ seq, top: codec.encode(top, {full: true}) }, state.zeros); }, @@ -317,5 +318,18 @@ const funcs = self.minion = { const topo4 = Object.assign(new Topo4(), cache.lathe); const heights = topo4.lathePath(stmp, tool); reply({ seq, heights }); + }, + + // cam widget shadow + + cam_shadow_stack(data, seq) { + cache.shadow_stack = data.clear ? undefined : data; + }, + + cam_shadow_z(data, seq) { + let polys = newWidget().computeShadowAtZ(data.z, data.t, cache.shadow_stack); + let coded = codec.encode(polys); + reply({ seq, data: coded }); } + }; diff --git a/src/kiri/run/worker.js b/src/kiri/run/worker.js index a63fbeeb..fa115dc0 100644 --- a/src/kiri/run/worker.js +++ b/src/kiri/run/worker.js @@ -6,28 +6,28 @@ import '../../add/three.js'; import { base } from '../../geo/base.js'; import { codec } from '../core/codec.js'; -import { consts } from '../core/consts.js'; -import { util } from '../../geo/base.js'; -import { newPoint } from '../../geo/point.js'; -import { polygons as POLY } from '../../geo/polygons.js'; -import { newPrint } from '../core/print.js'; -import { render } from '../core/render.js'; -import { wasm_ctrl } from '../../geo/wasm.js'; -import { version } from '../../moto/license.js'; -import { Widget, newWidget } from '../core/widget.js'; -import { load } from "../../load/png.js"; import { JSZip } from '../../ext/jszip-esm.js'; +import { load } from "../../load/png.js"; +import { newPoint } from '../../geo/point.js'; +import { newPrint } from '../core/print.js'; +import { polygons as POLY } from '../../geo/polygons.js'; import { RasterPath } from '../../gpu/raster.js'; +import { render } from '../core/render.js'; +import { util } from '../../geo/base.js'; +import { version } from '../../moto/license.js'; +import { wasm_ctrl } from '../../geo/wasm.js'; +import { Widget, newWidget } from '../core/widget.js'; -import { CAM } from '../mode/cam/driver-be.js'; -import { DRAG } from '../mode/drag/driver.js'; -import { FDM } from '../mode/fdm/driver-be.js'; -import { LASER } from '../mode/laser/driver.js'; -import { SLA } from '../mode/sla/driver.js'; -import { WEDM } from '../mode/wedm/driver.js'; -import { WJET } from '../mode/wjet/driver.js'; +import { CAM } from '../mode/cam/work/init-work.js'; +import { DRAG } from '../mode/drag/init-work.js'; +import { FDM } from '../mode/fdm/work/init-work.js'; +import { LASER } from '../mode/laser/init-work.js'; +import { SLA } from '../mode/sla/work/init-work.js'; +import { WEDM } from '../mode/wedm/init-work.js'; +import { WJET } from '../mode/wjet/init-work.js'; const { time } = util; +const POOLPATH = "./minion.js"; let drivers = { DRAG, @@ -58,6 +58,13 @@ function debug() { console.log(...arguments); } +function setPrint(print) { + if (current.print && current.print !== print) { + current.print.disposeSafeEval(); + } + return current.print = print; +} + // catch clipper alerts and convert to console messages self.alert = function(o) { console.log(o); @@ -66,7 +73,7 @@ self.alert = function(o) { self.uuid = ((Math.random() * Date.now()) | 0).toString(36); /** - * @returns {RasterPath} + * @returns {RasterPath} instantiated class */ self.get_raster_gpu = async function({ mode, resolution, rotationStep }) { let gpu = new RasterPath({ @@ -107,7 +114,7 @@ const minwork = { return; } for (let i=0; i < concurrent; i++) { - let minion = new Worker(poolpath || consts.PATHS.pool, { type: 'module' }); + let minion = new Worker(poolpath || POOLPATH, { type: 'module' }); minion.onerror = (error) => { debug({ MINION_ERROR: error }); error.preventDefault(); @@ -314,7 +321,7 @@ const dispatch = { // purge all sync data clear(data, send) { // current.snap = null; - current.print = null; + setPrint(null); dispatch.group = wgroup = {}; dispatch.cache = worker.cache = wcache = {}; Widget.Groups.clear(); @@ -342,6 +349,8 @@ const dispatch = { widget.vertices = vertices; // restore meta widget.meta = data.meta; + // restore annotations + widget.anno = data.anno; // restore tracking object widget.track = data.track; send.done(data.id); @@ -413,6 +422,18 @@ const dispatch = { send.done({}); }, + slicePre(data, send) { + const { settings } = data; + const { mode } = settings; + const driver = drivers[mode]; + + if (driver.slicePre) { + driver.slicePre(settings); + } + + send.done(); + }, + slice(data, send) { send.data({ update:0.001, updateStatus:"slicing" }); @@ -427,10 +448,9 @@ const dispatch = { let last = time(), now; - current.print = null; + setPrint(null); current.mode = settings.mode.toUpperCase(); - widget.anno = data.anno || widget.anno; widget.settings = settings; widget.clearSlices(); @@ -463,13 +483,13 @@ const dispatch = { }); }, - sliceAll(data, send) { + slicePost(data, send) { const { settings } = data; const { mode } = settings; const driver = drivers[mode]; - if (driver.sliceAll) { - driver.sliceAll(settings, send.data); + if (driver.slicePost) { + driver.slicePost(settings, send.data); } send.done({done: true}); @@ -496,7 +516,7 @@ const dispatch = { send.data(emit, state.zeros); }).then(() => { const unitScale = settings.controller.units === 'in' ? (1 / 25.4) : 1; - const print = current.print || {}; + const print = setPrint(current.print || {}); const minSpeed = (print.minSpeed || 0) * unitScale; const maxSpeed = (print.maxSpeed || 0) * unitScale; @@ -571,7 +591,7 @@ const dispatch = { z: origin.z - (process.camOriginOffZ ?? 0) }; const device = settings.device; - const print = current.print = newPrint(settings, Object.values(wcache)); + const print = setPrint(newPrint(settings, Object.values(wcache))); const tools = device.extruders; const mode = settings.mode; const thin = settings.controller.lineType === 'line' || mode !== 'FDM'; @@ -601,7 +621,7 @@ const dispatch = { out.point = newPoint(x,y,z || 0); }); }); - const print = current.print = newPrint(null, Object.values(wcache)); + const print = setPrint(newPrint(null, Object.values(wcache))); render.path(parsed, progress => { send.data({ progress }); }, { thin: true }) @@ -768,7 +788,6 @@ const worker = self.kiri_worker = { }; // initilize driver mode handlers -CAM.init(worker); -FDM.init(worker); -LASER.init(worker); -SLA.init(worker); +for (let driver of Object.values(drivers)) { + driver.init(worker); +} diff --git a/src/load/3mf.js b/src/load/3mf.js index bf392988..3a7554ac 100644 --- a/src/load/3mf.js +++ b/src/load/3mf.js @@ -212,6 +212,133 @@ function extractItems(records) { return outItems; } +/** + * Export vertex array(s) to 3MF format + * @param {Array} recs - array of {file: string, varr: Float32Array} records + * @param {Object} options - encoding options + * @param {String} [options.unit='millimeter'] - unit of measurement + * @param {String} [options.title='Model'] - model title + * @returns {Promise} 3MF file as a Blob + */ +export async function encode(recs, options = {}) { + const { + unit = 'millimeter', + title = 'Model' + } = options; + + // Build the 3D model XML + let objectId = 1; + let resources = []; + let buildItems = []; + + for (let rec of recs) { + let { file, varr } = rec; + let name = file || `object-${objectId}`; + + // Extract unique vertices and build triangle indices + let vertices = []; + let triangles = []; + let vertexMap = new Map(); // map "x,y,z" -> vertex index + let vertexIndex = 0; + + // Process triangles (every 9 floats = 3 vertices = 1 triangle) + for (let i = 0; i < varr.length; i += 9) { + let triIndices = []; + + // Process 3 vertices per triangle + for (let j = 0; j < 3; j++) { + let vi = i + j * 3; + let x = varr[vi]; + let y = varr[vi + 1]; + let z = varr[vi + 2]; + + // Create vertex key for deduplication + let key = `${x},${y},${z}`; + let idx = vertexMap.get(key); + + if (idx === undefined) { + idx = vertexIndex++; + vertexMap.set(key, idx); + vertices.push({ x, y, z }); + } + + triIndices.push(idx); + } + + triangles.push(triIndices); + } + + // Generate object XML + let objectXml = [` `]; + objectXml.push(' '); + objectXml.push(' '); + + for (let v of vertices) { + objectXml.push(` `); + } + + objectXml.push(' '); + objectXml.push(' '); + + for (let tri of triangles) { + objectXml.push(` `); + } + + objectXml.push(' '); + objectXml.push(' '); + objectXml.push(' '); + + resources.push(objectXml.join('\n')); + buildItems.push(` `); + + objectId++; + } + + // Construct the complete 3D model XML + let modelXml = [ + '', + ``, + ' ', + resources.join('\n'), + ' ', + ' ', + buildItems.join('\n'), + ' ', + '' + ].join('\n'); + + // Create [Content_Types].xml + let contentTypes = [ + '', + '', + ' ', + ' ', + '' + ].join('\n'); + + // Create _rels/.rels + let rels = [ + '', + '', + ' ', + '' + ].join('\n'); + + // Create ZIP archive + let zip = new JSZip(); + zip.file('[Content_Types].xml', contentTypes); + zip.file('_rels/.rels', rels); + zip.file('3D/3dmodel.model', modelXml); + + // Generate the 3MF file as a blob + return await zip.generateAsync({ + type: "uint8array", + compression: 'DEFLATE', + compressionOptions: { level: 6 }, + streamFiles: true + }); +} + /** * @param {Object} data binary file * @returns {Array} vertex face array @@ -229,4 +356,5 @@ export function parseAsync(data) { } resolve(extractItems(models)); }); -} \ No newline at end of file +} + diff --git a/src/load/obj.js b/src/load/obj.js index 4a8b1f01..ec767b0e 100644 --- a/src/load/obj.js +++ b/src/load/obj.js @@ -4,6 +4,28 @@ import { util } from '../geo/base.js'; +/** + * Export vertex array(s) to OBJ format string + * @param {Array} recs - array of {file: string, varr: Float32Array} records + * @param {String} header - optional header comment + * @returns {String} OBJ format text + */ +export function encode(recs, header = '# Generated @ https://grid.space') { + let p = 1; + let obj = [header]; + for (let rec of recs) { + let { file, varr } = rec; + obj.push(`g ${file || 'unnamed'}`); + for (let i=0; i -- All Rights Reserved */ +import { THREE } from '../ext/three.js'; + const CDH = 'Content-Disposition'; +const { Vector3, computeFaceNormal } = THREE; + +/** + * Encode vertex array(s) to binary STL format + * Automatically computes normals from triangle vertices + * @param {Array} recs - array of {file: string, varr: Float32Array} records + * @param {String} header - optional header text (max 80 chars) + * @returns {Uint8Array} binary STL data + */ +export function encode(recs, header = '') { + // Calculate total vertices + let vtot = 0; + for (let rec of recs) { + vtot += (rec.varr.length / 3); + } + + // Create STL buffer: 80 byte header + 4 byte count + (50 bytes per triangle) + let stl = new Uint8Array(80 + 4 + vtot/3 * 50); + let dat = new DataView(stl.buffer); + let pos = 84; + + // Write header text (first 80 bytes) + if (header) { + header.substring(0, 80).split('').forEach((c, i) => { + dat.setUint8(i, c.charCodeAt(0)); + }); + } + + // Write triangle count at byte 80 + dat.setInt32(80, vtot/3, true); + + // Write triangles + for (let rec of recs) { + let { varr } = rec; + for (let i = 0, l = varr.length; i < l;) { + // Read three vertices + let p0 = new Vector3(varr[i++], varr[i++], varr[i++]); + let p1 = new Vector3(varr[i++], varr[i++], varr[i++]); + let p2 = new Vector3(varr[i++], varr[i++], varr[i++]); + + // Compute face normal + let norm = computeFaceNormal(p0, p1, p2); + + // Write normal (3 floats) + dat.setFloat32(pos + 0, norm.x, true); + dat.setFloat32(pos + 4, norm.y, true); + dat.setFloat32(pos + 8, norm.z, true); + + // Write vertices (9 floats) + dat.setFloat32(pos + 12, p0.x, true); + dat.setFloat32(pos + 16, p0.y, true); + dat.setFloat32(pos + 20, p0.z, true); + dat.setFloat32(pos + 24, p1.x, true); + dat.setFloat32(pos + 28, p1.y, true); + dat.setFloat32(pos + 32, p1.z, true); + dat.setFloat32(pos + 36, p2.x, true); + dat.setFloat32(pos + 40, p2.y, true); + dat.setFloat32(pos + 44, p2.z, true); + + // Attribute byte count (2 bytes) - typically 0 + dat.setUint16(pos + 48, 0, true); + + pos += 50; + } + } + + return stl; +} export class STL { constructor() { diff --git a/src/main/kiri.js b/src/main/kiri.js index 918898a5..8e360c8d 100644 --- a/src/main/kiri.js +++ b/src/main/kiri.js @@ -3,31 +3,23 @@ import '../add/array.js'; import '../add/class.js'; import '../add/three.js'; -import '../kiri/core/lang.js'; -import '../kiri/core/lang-en.js'; +import '../kiri/app/language.js'; +import '../kiri/app/lang-en.js'; -import { broker } from '../moto/broker.js'; -import { run } from '../kiri/core/init.js'; +import { api } from '../kiri/app/api.js'; +import { init_lang } from '../kiri/app/init/lang.js'; +import { init_input } from '../kiri/app/init/input.js'; +import { init_sync } from '../kiri/app/init/sync.js'; let traceload = location.search.indexOf('traceload') > 0; let load = []; -if (traceload) { - broker.subscribe([ - "init.one", - "init.two", - "init.lang", - "init-done", - "load-done", - ], (msg, topic) => { - console.log(topic, '->', msg); - }) -} - -function safeExec(fn) { +function safeExec(fn, name) { try { if (traceload) { - console.log('kiri | exec |', fn); + console.log('kiri | exec |', name, fn); + } else if (name) { + console.log('kiri | load mods |', name); } fn(kiri.api); } catch (error) { @@ -35,14 +27,20 @@ function safeExec(fn) { } } -function checkReady() { +async function checkReady() { if (document.readyState === 'complete') { let bootctrl = navigator.serviceWorker.controller; console.log(`kiri | boot ctrl | ` + (bootctrl ? true : false)); - let api = kiri.api = run(); + kiri.api = api; self.$ = api.web.$; - for (let fn of load) { - safeExec(fn); + { + api.client.start(); + await init_lang(); + await init_input(); + await init_sync(); + } + for (let [fn, name] of load) { + safeExec(fn, name); } load = undefined; api.event.emit('load-done', stats); @@ -67,12 +65,12 @@ function checkReady() { } self.kiri = { - load(fn) { + load(fn, name) { // console.log('KIRI LOAD', [...arguments]); if (load) { - load.push(fn); + load.push([fn, name]); } else { - safeExec(fn); + safeExec(fn, name); } } }; diff --git a/src/mesh/api.js b/src/mesh/api.js index 080ab478..de17b7ac 100644 --- a/src/mesh/api.js +++ b/src/mesh/api.js @@ -1008,9 +1008,10 @@ let file = { h.input({ id: "filename", value: "mesh_export" }) ]), h.div([ - h.button({ _: "download OBJ", onclick() { doit('obj') } }), - h.button({ _: "download STL", onclick() { doit('stl') } }), - h.button({ _: "download STEP", onclick() { doit('step') } }), + h.button({ _: "3MF", onclick() { doit('3mf') } }), + h.button({ _: "OBJ", onclick() { doit('obj') } }), + h.button({ _: "STL", onclick() { doit('stl') } }), + h.button({ _: "STEP", onclick() { doit('step') } }), ]) ]) ] }); diff --git a/src/mesh/work.js b/src/mesh/work.js index 214854d4..ccbd0d54 100644 --- a/src/mesh/work.js +++ b/src/mesh/work.js @@ -17,6 +17,9 @@ import { CSG } from '../geo/csg.js'; import { util } from './util.js'; import { tool as meshTool } from './tool.js'; import { meshToSTEPWithFaces } from '../load/step.js'; +import { encode as tmfEncode } from '../load/3mf.js'; +import { encode as objEncode } from '../load/obj.js'; +import { encode as stlEncode } from '../load/stl.js'; const { Triangle, Vector3, BufferGeometry, BufferAttribute, computeFaceNormal } = THREE; @@ -517,51 +520,9 @@ const model = { } switch (format) { case "obj": - let p = 1; - let obj = [header]; - for (let rec of recs) { - let { file, varr } = rec; - obj.push(`g ${file}`); - for (let i=0; i { - dat.setUint8(i, c.charCodeAt(0)); - }); - // todo put Mesh:Tool info in header - dat.setInt32(80, vtot/3, true); - for (let rec of recs) { - let { id, matrix, file, varr } = rec; - for (let i=0, l=varr.length; i { - dat.setUint8(i, c.charCodeAt(0)); - }); - // todo put Mesh:Tool info in header - dat.setInt32(80, vtot/3, true); - for (let rec of recs) { - let { id, matrix, file, varr } = rec; - for (let i=0, l=varr.length; i send.done(data)); + return; default: throw `invalid format "${format}"`; } diff --git a/src/moto/broker.js b/src/moto/broker.js index 08ca8734..7682e30b 100644 --- a/src/moto/broker.js +++ b/src/moto/broker.js @@ -18,6 +18,10 @@ class Broker { return Object.keys(this.#topics); } + targets(topic) { + return this.#topics[topic]; + } + get send() { return this.#send; } diff --git a/src/moto/license.js b/src/moto/license.js index 33e5ebca..d307e578 100644 --- a/src/moto/license.js +++ b/src/moto/license.js @@ -3,9 +3,9 @@ const terms = { COPYRIGHT: "Copyright (C) Stewart Allen - All Rights Reserved", LICENSE: "See the license.md file included with the source distribution", - VERSION: "4.5.0" + VERSION: "4.6.0" }; -export const beta = 0; +export const beta = 4600; export const license = terms; export const version = terms.VERSION; diff --git a/src/moto/orbit.js b/src/moto/orbit.js index 74dd86b7..4e7204e8 100644 --- a/src/moto/orbit.js +++ b/src/moto/orbit.js @@ -354,7 +354,7 @@ class Orbit extends EventDispatcher { function getZoomScale(factor = 1) { // Use slower zoom for orthographic mode - let base = mode === MODE.ORTHOGRAPHIC ? 0.98 : 0.95; + let base = mode === MODE.ORTHOGRAPHIC ? 0.98 : 0.97; return Math.pow(base, factor); } @@ -461,7 +461,33 @@ class Orbit extends EventDispatcher { slider(null); - let delta = -(event.deltaY || event.wheelDelta || event.detail || 0); + // Normalize delta across browsers + // Goal: scroll up = negative delta = zoom in, scroll down = positive delta = zoom out + let delta = 0; + if (event.wheelDelta !== undefined) { + // Chrome/Safari wheelDelta: scroll up = +120, scroll down = -120 + // Negate to match deltaY convention + delta = -event.wheelDelta; + } else if (event.detail !== undefined) { + // Old Firefox DOMMouseScroll detail: scroll up = -3, scroll down = +3 + delta = event.detail * 40; // Normalize to pixel values + } else if (event.deltaY !== undefined) { + // Modern browsers deltaY: scroll up = negative, scroll down = positive + // Already matches our convention + delta = event.deltaY; + // Firefox's deltaMode indicates the unit of deltaY + // DOM_DELTA_PIXEL (0x00) - pixels + // DOM_DELTA_LINE (0x01) - lines (default for Firefox, ~3 units per notch) + // DOM_DELTA_PAGE (0x02) - pages + if (event.deltaMode === 1) { + // LINE mode: Firefox typically sends values around 3 per scroll notch + // Multiply by a smaller factor to make it less sensitive + delta = delta * 10; + } else if (event.deltaMode === 2) { + // PAGE mode: very large deltas + delta = delta * 120; + } + } if (delta === 0) return; @@ -657,8 +683,9 @@ class Orbit extends EventDispatcher { domEl.addEventListener('contextmenu', function (event) { event.preventDefault() }, false); domEl.addEventListener('mousedown', onMouseDown, false); - domEl.addEventListener('mousewheel', onMouseWheel, false); - domEl.addEventListener('DOMMouseScroll', onMouseWheel, false); // firefox + domEl.addEventListener('wheel', onMouseWheel, false); // Modern standard (Chrome, Safari, Firefox) + domEl.addEventListener('mousewheel', onMouseWheel, false); // Legacy Chrome/Safari + domEl.addEventListener('DOMMouseScroll', onMouseWheel, false); // Legacy Firefox domEl.addEventListener('touchstart', touchstart, false); domEl.addEventListener('touchend', touchend, false); domEl.addEventListener('touchmove', touchmove, false); diff --git a/src/moto/quickjs.js b/src/moto/quickjs.js new file mode 100644 index 00000000..f2db2b44 --- /dev/null +++ b/src/moto/quickjs.js @@ -0,0 +1,282 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +/** + * QuickJS VM Wrapper + * Provides a simplified interface for working with QuickJS contexts + */ + +import { getQuickJS } from '../ext/quickjs.js'; + +let quickJSInstance = null; + +/** + * Get or initialize the QuickJS singleton instance + */ +async function getQuickJSInstance() { + if (!quickJSInstance) { + quickJSInstance = await getQuickJS(); + } + return quickJSInstance; +} + +/** + * QuickJS VM wrapper with helper methods + * Automatically manages a QuickJS context lifecycle + */ +class QuickJSVM { + /** + * Create a new QuickJS VM context + * @param {Object} options - Optional VM configuration + */ + constructor(options = {}) { + this.vm = null; + this.disposed = false; + this.initPromise = this._init(options); + } + + /** + * Internal initialization (async) + */ + async _init(options) { + const QuickJS = await getQuickJSInstance(); + this.vm = QuickJS.newContext(options); + } + + /** + * Ensure VM is initialized before use + */ + async ready() { + await this.initPromise; + if (this.disposed) { + throw new Error('VM has been disposed'); + } + return this; + } + + /** + * Set multiple variables in the global context + * @param {Object} obj - Object with key/value pairs to set as globals + * @example + * vm.setContext({ + * x: 123, + * config: { width: 800, height: 600 }, + * items: [1, 2, 3] + * }); + */ + setContext(obj) { + for (const [key, value] of Object.entries(obj)) { + const handle = this.jsToVm(value); + this.vm.setProp(this.vm.global, key, handle); + handle.dispose(); + } + return this; + } + + /** + * Set a single global variable + * @param {string} name - Variable name + * @param {*} value - JavaScript value to set + * @example + * vm.set('x', 123); + * vm.set('config', { width: 800 }); + */ + set(name, value) { + const handle = this.jsToVm(value); + this.vm.setProp(this.vm.global, name, handle); + handle.dispose(); + return this; + } + + /** + * Get a global variable value + * @param {string} name - Variable name + * @returns {*} JavaScript value + * @example + * const x = vm.get('x'); // 123 + */ + get(name) { + const handle = this.vm.getProp(this.vm.global, name); + const value = this.vm.dump(handle); + handle.dispose(); + return value; + } + + /** + * Convert JavaScript value to QuickJS handle + * @param {*} value - JavaScript value + * @returns {QuickJSHandle} QuickJS handle (caller must dispose) + */ + jsToVm(value) { + // Handle primitives + if (value === null) { + return this.vm.null; + } + + if (value === undefined) { + return this.vm.undefined; + } + + if (typeof value === 'number') { + return this.vm.newNumber(value); + } + + if (typeof value === 'string') { + return this.vm.newString(value); + } + + if (typeof value === 'boolean') { + return value ? this.vm.true : this.vm.false; + } + + // Handle arrays + if (Array.isArray(value)) { + const arr = this.vm.newArray(); + for (let i = 0; i < value.length; i++) { + const itemHandle = this.jsToVm(value[i]); + this.vm.setProp(arr, i, itemHandle); + itemHandle.dispose(); + } + return arr; + } + + // Handle objects + if (typeof value === 'object') { + const obj = this.vm.newObject(); + for (const [k, v] of Object.entries(value)) { + const propHandle = this.jsToVm(v); + this.vm.setProp(obj, k, propHandle); + propHandle.dispose(); + } + return obj; + } + + // Fallback for unsupported types + console.warn(`Unsupported type for VM: ${typeof value}, setting to undefined`); + return this.vm.undefined; + } + + /** + * Evaluate JavaScript code in the VM + * @param {string} code - JavaScript code to evaluate + * @returns {*} Result value or throws error + * @example + * const result = vm.eval('1 + 2'); // 3 + */ + eval(code) { + const result = this.vm.evalCode(code); + + if (result.error) { + const error = this.vm.dump(result.error); + result.error.dispose(); + throw new Error(`VM Error: ${error}`); + } + + const value = this.vm.dump(result.value); + result.value.dispose(); + return value; + } + + /** + * Evaluate code and return raw result handle (caller must dispose) + * @param {string} code - JavaScript code to evaluate + * @returns {Object} { error?: handle, value?: handle } + */ + evalRaw(code) { + return this.vm.evalCode(code); + } + + /** + * Create a JavaScript function that can be called from VM code + * @param {string} name - Function name in VM + * @param {Function} fn - JavaScript function to wrap + * @example + * vm.setFunction('add', (a, b) => a + b); + * vm.eval('add(2, 3)'); // 5 + */ + setFunction(name, fn) { + const fnHandle = this.vm.newFunction(name, (...args) => { + // Convert VM args to JS + const jsArgs = args.map(arg => this.vm.dump(arg)); + + // Call JS function + const result = fn(...jsArgs); + + // Convert result back to VM + return this.jsToVm(result); + }); + + this.vm.setProp(this.vm.global, name, fnHandle); + fnHandle.dispose(); + return this; + } + + /** + * Create an object with JSON-like structure + * @param {Object} data - JavaScript object to convert + * @returns {QuickJSHandle} Handle to VM object (caller must dispose) + */ + newJSON(data) { + return this.vm.unwrapResult( + this.vm.evalCode(`(${JSON.stringify(data)})`) + ); + } + + /** + * Execute a function in the VM with arguments + * @param {string} funcName - Function name in VM + * @param {...*} args - Arguments to pass + * @returns {*} Result value + * @example + * vm.eval('function add(a, b) { return a + b; }'); + * vm.call('add', 2, 3); // 5 + */ + call(funcName, ...args) { + const argsStr = args.map(a => JSON.stringify(a)).join(', '); + return this.eval(`${funcName}(${argsStr})`); + } + + /** + * Check if VM has been disposed + */ + isDisposed() { + return this.disposed; + } + + /** + * Get direct access to underlying QuickJS context + * Use with caution - you're responsible for handle management + */ + getContext() { + return this.vm; + } + + /** + * Dispose the VM and free all resources + * Must be called when done to prevent memory leaks + */ + dispose() { + if (!this.disposed && this.vm) { + this.vm.dispose(); + this.disposed = true; + this.vm = null; + } + } +} + +/** + * Create and initialize a new QuickJS VM + * @param {Object} options - Optional VM configuration + * @returns {Promise} Initialized VM instance + * @example + * const vm = await createVM(); + * vm.set('x', 123); + * const result = vm.eval('x * 2'); // 246 + * vm.dispose(); + */ +async function createVM(options = {}) { + const vm = new QuickJSVM(options); + await vm.ready(); + return vm; +} + +export { QuickJSVM, createVM }; diff --git a/src/moto/space.js b/src/moto/space.js index 524c07e7..b74074f0 100644 --- a/src/moto/space.js +++ b/src/moto/space.js @@ -1,8 +1,8 @@ /** Copyright Stewart Allen -- All Rights Reserved */ -import { broker } from './broker.js'; import { THREE } from '../ext/three.js'; import { Orbit } from './orbit.js'; +import { Text3D } from './text3d.js'; import '../ext/tween.js'; const { @@ -406,44 +406,17 @@ function showVolume(bool) { requestRefresh(); } -function canvasInMesh(w, h, textAlign, textBaseline, color, size) { - let canvas = document.createElement('canvas'), - canvasTexture = new THREE.CanvasTexture(canvas), - plane = new THREE.PlaneGeometry(w, h), - context = canvas.getContext('2d'), - scale = 8; - - canvas.width = w * scale; - canvas.height = h * scale; - - canvas.addEventListener("webglcontextlost", event => { - console.log('WEB GL CONTEXT LOST'); - event.preventDefault(); - }, false); - - canvas.addEventListener("webglcontextrestored", event => { - console.log('WEB GL CONTEXT RESTORED'); - }, false); - - try { - context.scale(scale, scale); - } catch (e) { - console.log('unable to create label canvas', e ? e.name : 'unknown'); - return {}; - } - context.fillStyle = color || fontColor; - context.font = `${size}px sans-serif`; - context.textAlign = textAlign; - context.textBaseline = textBaseline; - canvasTexture.minFilter = THREE.LinearFilter; - - // set 'transparent' to false to debug mesh bounds - let material = new THREE.MeshBasicMaterial({transparent: true, map: canvasTexture}); - let mesh = new THREE.Mesh(plane, material); - - return { context, mesh }; -} - +/** + * 3D text renderer for grid labels. + * Uses bitmap font atlas with shared geometries for memory efficiency. + */ +let text3d = self.document ? new Text3D({ + chars: '0123456789-XY', + charSize: 64, + kerning: 0.6, + scaleX: 0.7, + fontFamily: "'Russo One', sans-serif" +}) : undefined; function setRulers( xon = ruler.xon, @@ -475,50 +448,67 @@ function updateRulers() { oldView = ruler.view, view = ruler.view = new THREE.Group(); - if (xon && axesOn) { - let xPadding = labelSize * 4, - canvas = canvasInMesh(x + xPadding, labelSize * 3, 'center', 'top', rulerColor, labelSize), - context = canvas.context, - mesh = canvas.mesh; - if (!(context && mesh)) return; + if (false) console.log('updateRulers called', { + xon, yon, axesOn, labelSize, rulerColor, factor, + x1: ruler.x1, x2: ruler.x2, y1: ruler.y1, y2: ruler.y2, + xo: ruler.xo, yo: ruler.yo, + originX: grid.origin.x, originY: grid.origin.y + }); - if (platformBelt) { - for (let i = 0; i <= ruler.x2; i += grid.unitMajor) { - context.fillText((i * factor).round(1).toString(), ruler.x2 - i + xPadding / 2, 0); - } - } else { - for (let i = 0; i >= ruler.x1; i -= grid.unitMajor) { - context.fillText((i * factor).round(1).toString(), ruler.xo + i + xPadding / 2, 0); - } - for (let i = 0; i <= ruler.x2; i += grid.unitMajor) { - context.fillText((i * factor).round(1).toString(), ruler.xo + i + xPadding / 2, 0); - } + if (xon && axesOn) { + // Create X-axis numeric labels + // Original canvas code draws at canvas X: ruler.xo + i + xPadding/2 + // Canvas is centered at world X=0, canvas width = x + xPadding + // So world X = (canvasX - canvasWidth/2) = (ruler.xo + i + xPadding/2) - (x + xPadding)/2 + // = ruler.xo + i - x/2 = ruler.xo + i - w + for (let i = 0; i >= ruler.x1; i -= grid.unitMajor) { + const value = (i * factor).round(1).toString(); + const label = text3d.createLabel(value, labelSize, rulerColor, 'center'); + label.position.set(ruler.xo + i - w, -h - labelSize, zp); + label.rotation.x = Math.PI; + view.add(label); + } + for (let i = 0; i <= ruler.x2; i += grid.unitMajor) { + const value = (i * factor).round(1).toString(); + const label = text3d.createLabel(value, labelSize, rulerColor, 'center'); + label.position.set(ruler.xo + i - w, -h - labelSize, zp); + label.rotation.x = Math.PI; + view.add(label); } - context.font = (labelSize * 0.75) + 'px sans-serif'; - context.fillText(xlabel, (x + xPadding) / 2, labelSize * 1.5); - mesh.position.set(0, - h - labelSize * 2, zp); - view.add(mesh); + // Create X-axis label + const xLabel = text3d.createLabel(xlabel, labelSize, rulerColor, 'center'); + xLabel.position.set(0, -h - labelSize * 3.5, zp); + xLabel.rotation.x = Math.PI; + view.add(xLabel); } if (yon && axesOn) { - let yPadding = labelSize, - canvas = canvasInMesh(labelSize * 4, y + yPadding, 'end', 'middle', rulerColor, labelSize), - context = canvas.context, - mesh = canvas.mesh; - if (!(context && mesh)) return; - + // Create Y-axis numeric labels + // Original canvas code draws at canvas Y: y - (ruler.yo + i) + yPadding/2 + // Canvas is centered at world Y=0, canvas height = y + yPadding + // Canvas Y goes down, but world Y goes up, so we need to negate + // World Y = -(canvasY - canvasHeight/2) = -(h - ruler.yo - i) = -h + ruler.yo + i for (let i = 0; i >= ruler.y1; i -= grid.unitMajor) { - context.fillText((i * factor).round(1), labelSize * 4, y - (ruler.yo + i) + yPadding / 2); + const value = (i * factor).round(1).toString(); + const label = text3d.createLabel(value, labelSize, rulerColor, 'right'); + label.position.set(-w - labelSize + 3, -h + ruler.yo + i, zp); + label.rotation.x = Math.PI; + view.add(label); } for (let i = 0; i <= ruler.y2; i += grid.unitMajor) { - context.fillText((i * factor).round(1), labelSize * 4, y - (ruler.yo + i) + yPadding / 2); + const value = (i * factor).round(1).toString(); + const label = text3d.createLabel(value, labelSize, rulerColor, 'right'); + label.position.set(-w - labelSize + 3, -h + ruler.yo + i, zp); + label.rotation.x = Math.PI; + view.add(label); } - context.font = (labelSize * 0.75) + 'px sans-serif'; - context.fillText(ylabel, labelSize * 1.25, (y + yPadding) / 2); - mesh.position.set(-w - labelSize * 2 - 5, 0, zp); - view.add(mesh); + // Create Y-axis label + const yLabel = text3d.createLabel(ylabel, labelSize, rulerColor, 'center'); + yLabel.position.set(-w - labelSize * 4, 0, zp); + yLabel.rotation.x = Math.PI; + view.add(yLabel); } Space.scene.remove(oldView); @@ -979,7 +969,7 @@ function onMouseUp(event) { function onMouseMove(event) { updateLastAction(); - let int, vis; + let int, vis, dragTrack; const mv = new THREE.Vector2(); mv.x = ( event.clientX / window.innerWidth ) * 2 - 1; @@ -1000,13 +990,18 @@ function onMouseMove(event) { platform.visible = vis; if (int && int.length > 0) platformHover(int[0].point); } - } else if (mouseDragPoint && mouseDrag && mouseDrag()) { + } else if (mouseDragPoint && mouseDrag && (dragTrack = mouseDrag())) { event.preventDefault(); let trackTo = alignedTracking ? trackPlane : platform; let vis = trackTo.visible; trackTo.visible = true; - int = intersect([trackTo], false); - trackTo.visible = vis; + if (dragTrack.length) { + int = intersect(dragTrack, false); + trackTo = dragTrack[0]; + } else { + int = intersect([trackTo], false); + trackTo.visible = vis; + } if (int.length > 0 && int[0].object === trackTo) { let delta = mouseDragPoint.clone().sub(int[0].point); let offset = mouseDragStart.clone().sub(int[0].point); @@ -1020,7 +1015,7 @@ function onMouseMove(event) { x: -offset.x, y: offset.z, z: 0 - }); + }, false, int); requestRefresh(); } } @@ -1335,7 +1330,7 @@ let Space = { } viewControl.update(); - if (then) then(); + if (typeof(then) === 'function') then(); }, setCtrl: (name) => { if (name === 'onshape') { diff --git a/src/moto/text3d.js b/src/moto/text3d.js new file mode 100644 index 00000000..81d26a3d --- /dev/null +++ b/src/moto/text3d.js @@ -0,0 +1,245 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { THREE } from '../ext/three.js'; + +/** + * 3D Bitmap Text Renderer using font atlas. + * Creates a texture atlas from specified characters and renders text using + * individual character meshes with shared geometries and material. + */ +class Text3D { + /** + * Create a 3D text renderer. + * @param {object} options - Configuration options + * @param {string} options.chars - Characters to include in atlas (default: '0123456789-XY') + * @param {number} options.charSize - Size of each character in atlas texture (default: 64) + * @param {number} options.kerning - Character spacing multiplier (default: 0.5, lower = tighter) + * @param {number} options.scaleX - Default horizontal scale (default: 1.0) + * @param {number} options.scaleY - Default vertical scale (default: 1.0) + * @param {string} options.fontFamily - CSS font family string (default: 'sans-serif') + */ + constructor(options = {}) { + this.chars = options.chars || '0123456789-XY'; + this.charSize = options.charSize || 64; + this.kerning = options.kerning !== undefined ? options.kerning : 0.5; + this.scaleX = options.scaleX !== undefined ? options.scaleX : 1.0; + this.scaleY = options.scaleY !== undefined ? options.scaleY : 1.0; + this.fontFamily = options.fontFamily || "sans-serif"; + this.atlas = null; + this.geometries = {}; // Cached geometries per character + this.material = null; // Shared material for all characters + this._initialize(); + } + + /** + * Initialize atlas, geometries, and material. + * @private + */ + _initialize() { + this._createAtlas(); + this._createGeometries(); + this._createMaterial(); + } + + /** + * Create bitmap font atlas texture. + * @private + */ + _createAtlas() { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + const charSize = this.charSize; + + canvas.width = charSize * this.chars.length; + canvas.height = charSize; + + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.font = `bold ${charSize * 0.8}px ${this.fontFamily}`; + ctx.fillStyle = 'white'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + const uvMap = {}; + for (let i = 0; i < this.chars.length; i++) { + const char = this.chars[i]; + ctx.fillText(char, i * charSize + charSize/2, charSize/2); + uvMap[char] = { + uStart: i / this.chars.length, + uEnd: (i + 1) / this.chars.length + }; + } + + const texture = new THREE.CanvasTexture(canvas); + texture.minFilter = THREE.LinearFilter; + texture.magFilter = THREE.LinearFilter; + texture.needsUpdate = true; + + this.atlas = { texture, uvMap, canvas }; + + if (self.debug) { + console.log('Text3D atlas created', { + width: canvas.width, + height: canvas.height, + chars: this.chars.length + }); + } + } + + /** + * Create cached geometries for each character. + * Each character gets one geometry with UVs mapped to atlas position. + * These geometries are reused across all labels. + * @private + */ + _createGeometries() { + const { uvMap } = this.atlas; + + for (let char of this.chars) { + const uvData = uvMap[char]; + if (!uvData) continue; + + // Create unit-sized plane (will be scaled when used) + const geometry = new THREE.PlaneGeometry(1, 1); + const uvAttr = geometry.attributes.uv; + + // Set UV coordinates to sample this character from atlas + uvAttr.setXY(0, uvData.uStart, 0); // bottom-left + uvAttr.setXY(1, uvData.uEnd, 0); // bottom-right + uvAttr.setXY(2, uvData.uStart, 1); // top-left + uvAttr.setXY(3, uvData.uEnd, 1); // top-right + + this.geometries[char] = geometry; + } + } + + /** + * Create shared material for all characters. + * @private + */ + _createMaterial() { + this.material = new THREE.MeshBasicMaterial({ + map: this.atlas.texture, + transparent: true, + opacity: 0.9, + depthWrite: false, + side: THREE.DoubleSide + }); + } + + /** + * Create a 3D text label. + * @param {string} text - Text to render + * @param {number} size - Character height in world units + * @param {string|number} color - Text color (CSS string or hex number) + * @param {string} align - Horizontal alignment ('left', 'center', 'right') + * @param {number} kerning - Optional kerning override for this label + * @param {number} scaleX - Optional horizontal scale override for this label + * @param {number} scaleY - Optional vertical scale override for this label + * @returns {THREE.Group} Group containing character meshes + */ + createLabel(text, size, color = 0x333333, align = 'center', kerning, scaleX, scaleY) { + const group = new THREE.Group(); + const spacing = kerning !== undefined ? kerning : this.kerning; + const sx = scaleX !== undefined ? scaleX : this.scaleX; + const sy = scaleY !== undefined ? scaleY : this.scaleY; + const charSpacing = size * spacing * sx; // Distance between character centers (account for width scale) + const totalWidth = text.length * charSpacing; + + // Convert CSS color string to THREE.Color if needed + let threeColor = color; + if (typeof color === 'string') { + threeColor = new THREE.Color(color); + } + + // Calculate starting X based on alignment + let startX = 0; + if (align === 'center') { + startX = -totalWidth / 2; + } else if (align === 'right') { + startX = -totalWidth; + } + + // Create mesh for each character using cached geometry + for (let i = 0; i < text.length; i++) { + const char = text[i]; + const geometry = this.geometries[char]; + + if (!geometry) { + if (self.debug) { + console.warn('Text3D: Character not in atlas:', char); + } + continue; + } + + // Clone material to set per-label color + const material = this.material.clone(); + material.color = threeColor; + + const mesh = new THREE.Mesh(geometry, material); + + // Apply size and scale + mesh.scale.set(size * sx, size * sy, 1); + mesh.position.x = startX + i * charSpacing + charSpacing / 2; + + group.add(mesh); + } + + return group; + } + + /** + * Update kerning for future labels. + * @param {number} kerning - New kerning value + */ + setKerning(kerning) { + this.kerning = kerning; + } + + /** + * Update horizontal scale for future labels. + * @param {number} scaleX - New horizontal scale value + */ + setScaleX(scaleX) { + this.scaleX = scaleX; + } + + /** + * Update vertical scale for future labels. + * @param {number} scaleY - New vertical scale value + */ + setScaleY(scaleY) { + this.scaleY = scaleY; + } + + /** + * Update both scales for future labels. + * @param {number} scaleX - New horizontal scale value + * @param {number} scaleY - New vertical scale value + */ + setScale(scaleX, scaleY) { + this.scaleX = scaleX; + this.scaleY = scaleY; + } + + /** + * Clean up resources. + */ + dispose() { + // Dispose texture + if (this.atlas && this.atlas.texture) { + this.atlas.texture.dispose(); + } + + // Dispose geometries + for (let char in this.geometries) { + this.geometries[char].dispose(); + } + + // Dispose material + if (this.material) { + this.material.dispose(); + } + } +} + +export { Text3D }; diff --git a/web/kiri/index.css b/web/kiri/index.css index 53ec1d71..b5f49987 100644 --- a/web/kiri/index.css +++ b/web/kiri/index.css @@ -272,6 +272,9 @@ details[open] summary::after { background-color: var(--blue-2); color: black; } +.dark #alert-border:hover { + background-color: rgba(255,255,255,0.7); +} .dark #progress { background-color: #333; border-top: 1px solid #555; @@ -413,6 +416,8 @@ details[open] summary::after { } .dark #rnfo { color: #aaa; + border: 1px solid rgba(255,255,255,0.25); + background-color: rgba(0,0,0,0.75); } .dark #rnfo label { color: #ada; @@ -1334,10 +1339,12 @@ details[open] summary::after { cursor: pointer; } .ext-buttons button { + display: inline-flex; + line-height: 1; justify-content: center; align-items: center; text-align: center; - flex-grow: 1; + flex: 1 1 0; /* equal width */ margin-top: 3px; height: 75%; } @@ -1419,6 +1426,11 @@ details[open] summary::after { word-break: break-word; white-space: normal; } +.z-select { + aspect-ratio: 1; + border-radius: 50%; + border-color: rgba(128,128,128,0.5) !important; +} #oplist { gap: 3px; margin-bottom: 3px; @@ -1436,6 +1448,21 @@ details[open] summary::after { #oplist .clock:hover { background-color: var(--blue-2); } +#oplist .loop:hover { + background-color: rgba(100, 150, 255, 0.35); +} +#oplist .in-loop { + margin-left: 10px; +} +#oplist .in-loop::before { + content: ""; + border: 1px solid var(--blue-2); + border-radius: 3px; + width: 2px; + height: 80%; + position: absolute; + left: -10px; +} #oplist > div { padding: 4px 20px 4px 7px !important; } @@ -1502,9 +1529,10 @@ details[open] summary::after { font-size: larger; } #op-add-pop { + pointer-events: all !important; position: absolute; display: none; - top: 0; + top: 50%; right: 100%; transform: translateY(-50%); } @@ -1513,7 +1541,7 @@ details[open] summary::after { } .op-grid { display: grid !important; - grid-template-columns: 1fr 1fr; + grid-template-columns: 1fr 1fr 1fr; grid-template-rows: auto; align-items: center; gap: 5px; @@ -2528,6 +2556,10 @@ details[open] summary::after { color: #000; flex-direction: column; align-items: flex-end; + white-space: nowrap; +} +#stats hr { + width: 100%; } #stats:hover #rnfo { display: grid; @@ -2539,8 +2571,8 @@ details[open] summary::after { bottom: 100%; gap: 3px; right: 0; - background-color: rgba(255,255,255,0.05); - border: 1px solid black; + background-color: rgba(255,255,255,0.75); + border: 1px solid rgba(0,0,0,0.5); border-radius: 3px; padding: 5px; } @@ -2605,3 +2637,17 @@ details[open] summary::after { .range-detail span { flex-grow: 1; } + +.devel #modal-box { + height: 80%; +} +.devel #mod-x-any { + height: 100%; +} +.devel #code-preview { + flex-grow: 1; +} +.devel #code-preview > div, +.devel #code-preview-textarea { + height: 100%; +} \ No newline at end of file diff --git a/web/kiri/index.html b/web/kiri/index.html index a04611f0..5e858104 100644 --- a/web/kiri/index.html +++ b/web/kiri/index.html @@ -22,8 +22,8 @@ - + + @@ -110,8 +110,8 @@
- - + +

@@ -447,21 +447,22 @@
- +
+ operation list
-
index
-
lathe
laser on
laser off
+
index
+
lathe
flip
register
-
drill
level
+
drill
helical
gcode
contour
@@ -470,10 +471,12 @@
rough
outline
area
+
loop
+
@@ -675,22 +678,42 @@ - +
-
-
+
+
-
-
+
+ + +
+
+ + +
+
+ + +
- + + +
+
+
-
@@ -716,7 +739,7 @@
- +
diff --git a/web/kiri/lang/en.js b/web/kiri/lang/en.js index 82816abe..58546d42 100644 --- a/web/kiri/lang/en.js +++ b/web/kiri/lang/en.js @@ -8,8 +8,9 @@ self.lang['en-us'] = { axis: "axis", // left object scale pop menu back: "back", clear: "clear", // clear workspace (remove all objects) - copy: "copy", clone: "clone", // create duplicate + contents: "contents", + copy: "copy", delete: "delete", detail: "detail", done: "done", @@ -136,8 +137,6 @@ self.lang['en-us'] = { dv_xtab_l: "extrusion moves absolute", dv_orgc_s: "origin center", dv_orgc_l: "bed origin center", - // dv_orgt_s: "origin top", - // dv_orgt_l: "part z origin top", dv_bedc_s: "circular bed", dv_bedc_l: "device bed is circular", dv_belt_s: "belt bed", @@ -325,9 +324,12 @@ self.lang['en-us'] = { op_anim_l: ["animation mesh density","higher is more dense","takes more memory","and is slower"], op_mani_s: "manifold", op_mani_l: ["in stock indexed mode","use boolean visualiation","this is very expensive","and likely very slow"], - op_exgl_l: ["show Grid:Local devices in export dialog","includes Grid:Bots and OctoPrint plugins"], - op_exgh_l: ["legacy Grid:Host local network support"], - op_exop_l: ["legacy OctoPrint local network support","Grid:Space OctoPrint plugin recommended"], + op_exop_s: "remote host", + op_exop_l: ["enable sending files to remote hosts","moonraker = klipper","octoprint"], + op_excp_s: "code preview", + op_excp_l: ["show start of gcode","in export dialog"], + op_exth_s: "thumbnail", + op_exth_l: ["embed print thumbnail in gcode"], lo_menu: "layout", @@ -394,7 +396,7 @@ self.lang['en-us'] = { fi_over_l: ["overlap between shells and solid or sparse infill. can improve bonding to shells","as fraction of line width","0.0 - 1.0"], fi_angl_s: "start angle", fi_angl_l: ["starting angle in degrees","90 degrees added to ","each following layer","applies only to solid layers"], - fi_grow_s: "area expand", + fi_grow_s: "expand", fi_grow_l: ["expand projected solids","can help with solid areas","over sparse infill areas","units in millimeters"], // FDM FILL @@ -463,30 +465,26 @@ self.lang['en-us'] = { // FDM SUPPORT sp_menu: "support", sp_detect: "detect", + sp_type_s: "type", + sp_type_l: ["automatic = detect","manual = paint on"], sp_dens_s: "density", sp_dens_l: ["fraction 0.0 - 1.0","recommended 0.15","0 to disable"], - sp_size_s: "pillar size", - sp_size_l: ["pillar width in millimeters","smaller values exponentially","increase detection times"], sp_offs_s: "part offset", sp_offs_l: ["offset from part","in millimeters"], - sp_gaps_s: "gap layers", + sp_gaps_s: "layer gap", sp_gaps_l: ["number of layers","offset from part"], sp_span_s: "max span", sp_span_l: ["unsupported span that causes","a new support to be generated","in millimeters"], - sp_angl_s: "max angle", + sp_angl_s: "angle", sp_angl_l: ["max overhang angle before","supporting pillar is generated","normal values 45-60","belt values 0-10"], - sp_area_s: "min area", - sp_area_l: ["minimum area for","a support column","in millimeters","smaller values will","slow detection"], sp_xpnd_s: "expand", sp_xpnd_l: ["expand support area","beyond part boundary","in millimeters"], - sp_grow_s: "grow", - sp_grow_l: ["grow support column to fill small gaps","in millimeters"], - sp_nozl_s: "extruder", - sp_nozl_l: ["in multi-extruder systems","the extruder to use for","support material"], + sp_nozl_s: "tool", + sp_nozl_l: ["in multi-extruder systems","the toolhead to use for","support material"], sp_outl_s: "enclosed", sp_outl_l: ["enclose support pattern","inside a perimeter shell"], - sp_auto_s: "automatic", - sp_auto_l: ["enable generated supports","using at-slice-time geometry","supports will only appear","after slicing completes"], + sp_tree_s: "tree type", + sp_tree_l: ["use tree type support structure"], // CNC COMMON terms cc_tool: "tool", @@ -584,6 +582,8 @@ self.lang['en-us'] = { cf_linx_l: "linear y-axis finishing", cf_liny_s: "enable x pass", cf_liny_l: "linear x-axis finishing", + cf_clip_s: "clip to stock", + cf_clip_l: ["contour op only","clip cutting paths","to defined stock"], // CNC TRACE cu_menu: "trace", @@ -603,6 +603,8 @@ self.lang['en-us'] = { cp_cont_l: ["ignore interior voids and features"], cp_outl_s: "outline only", cp_outl_l: ["ignore interior voids and features"], + cp_shad_s: "shadow", + cp_shad_l: ["select part shadow as work area. overrides selected edges and surface areas"], // CNC DRILLING cd_menu: "drill", @@ -711,6 +713,18 @@ self.lang['en-us'] = { ct_nabl_s: "auto", ct_nabl_l: ["auto generate radial tabs","projected from part center","using count and angle offset"], + // CNC LATHE + cl_xpnd_s: "expand", + cl_xpnd_l: ["grow starting radius for step down. radius is defined as the highest Z point measured from the X axis center of the part"], + + // CNC AREA OP + ca_altr_s: "alternate", + ca_altr_l: "alternate direction of travel lines (zigzag)", + ca_sang_s: "step angle", + ca_sang_l: "crossing angle on the XY plane for contour lines", + ca_wall_s: "walls", + ca_wall_l: ["perform a single pass along vertical faces. leave interior spaces. similar to outline operation"], + // OUTPUT ou_menu: "output", @@ -837,8 +851,6 @@ self.lang['en-us'] = { cs_hght_l: ["height (z) in workspace units","0 defaults to part size"], cs_offs_s: "offset", cs_offs_l: ["use width, depth, height","as offsets from max","part size on platform"], - cs_clip_s: "clip to", - cs_clip_l: ["contour op only","clip cutting paths","to defined stock"], cs_ishg_s: "show grid", cs_ishg_l: ["show platform grid in indexed mode"], cs_indx_s: "indexed", @@ -879,8 +891,10 @@ self.lang['en-us'] = { ad_rdwl_l: ["time between re-engaging","filament and movement","in milliseconds"], ad_scst_s: "shell coast", ad_scst_l: ["non-printing end","of perimeter shells","in millimeters"], - ad_msol_s: "area filter", + ad_msol_s: "min area", ad_msol_l: ["minimum area (mm^2)","bridges and flats less than this are culled"], + ad_mthk_s: "min thick", + ad_mthk_l: ["minimum area thickness","which is a ratio of area to perimeter","for layer differences which are used","to find bridges and flat areas","values from 0.0 - 0.5 work best"], ad_mins_s: "min speed", ad_mins_l: ["minimum speed","for short segments"], ad_maxf_s: "max flowrate", @@ -889,8 +903,6 @@ self.lang['en-us'] = { ad_scar_l: ["ease up length for scarf joint. disables arc detection","0 = disabled"], ad_spol_s: "short path", ad_spol_l: ["polygons shorter than this","will have their print speed","scaled down to min speed","in millimeters"], - ad_arct_s: "arc tolerance", - ad_arct_l: ["convert faceted lines to arcs","center point drift tolerance","when matching arc points","consider values around 0.15","value in millimeters","0 or scarf disables this"], ad_zhop_s: "z hop distance", ad_zhop_l: ["amount to raise z","on retraction moves","in millimeters","0 to disable"], ad_abkl_s: "anti-backlash", @@ -1007,9 +1019,10 @@ self.lang['en-us'] = { ex_gcpv: "gcode preview", ex_dwnl: "download", ex_bamb: "send to bambu", - ex_octo: "octoprint", + ex_octo: "remote host", ex_host: "host", ex_akey: "key", + ex_type: "remote print type", ex_rsnm: "resin used ML", ex_layr: "layers", ex_ptim: "print time", diff --git a/web/kiri/manifest.json b/web/kiri/manifest.json index 09fe05cc..b7ba182c 100644 --- a/web/kiri/manifest.json +++ b/web/kiri/manifest.json @@ -1,5 +1,5 @@ { - "name": "Kiri:Moto 4.5.0", + "name": "Kiri:Moto 4.6.0", "short_name": "Kiri:Moto", "description": "Slicer for 3D printers, CNC mills, laser cutters and more", "start_url": "/boot/index.html",