moved kiri/ui to kiri/app

This commit is contained in:
Stewart Allen 2025-12-25 01:44:20 -05:00
commit d8ddb45bcb
71 changed files with 43 additions and 43 deletions

75
src/kiri/app/alerts.js Normal file
View file

@ -0,0 +1,75 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { api } from './api.js';
let alerts = [];
function show(message, time) {
if (message === undefined || message === null) {
return update(true);
}
let rec = [message, Date.now(), time, true];
if (api.feature.alert_event) {
api.event.emit('alert', rec);
} else {
alerts.push(rec);
setTimeout( update, time+0.02);
update();
}
return rec;
}
function hide(rec, recs) {
if (Array.isArray(recs)) {
for (let r of recs) {
hide(r);
}
return;
}
if (api.feature.alert_event) {
api.event.emit('alert.cancel', rec);
return;
}
if (Array.isArray(rec)) {
rec[3] = false;
update();
}
}
function update(clear) {
if (clear) {
alerts = [];
}
const now = Date.now();
const { ui } = api;
// filter out by age and active flag
alerts = alerts.filter(alert => {
return alert[3] && (now - alert[1]) < ((alert[2] || 5) * 1000);
});
// limit to 5 showing
while (alerts.length > 5) {
alerts.shift();
}
// return if called before UI configured
if (!ui.alert) {
return;
}
if (alerts.length > 0) {
ui.alert.text.innerHTML = alerts.map(v => ['<p>',v[0],'</p>'].join('')).join('');
ui.alert.dialog.style.display = 'flex';
} else {
ui.alert.dialog.style.display = 'none';
}
}
export {
hide,
show,
update
};
export default {
hide,
show,
update
};

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

@ -0,0 +1,226 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import '../../ext/base64.js';
import alerts from './alerts.js';
import settings from './config/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 work } from './client.js';
import { consts, COLOR as color, LISTS as lists } from '../core/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 './lang.js';
import { local } from './local-storage.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 '../core/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 './select.js';
import { settingsUI } from './config/dialog.js';
import { showDevices } from './devices.js';
import { showTools } from '../mode/cam/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 './component.js';
import { updateTool } from '../mode/cam/tools.js';
import { util as utilModule } from './util.js';
import { view as viewModule } from './view-state.js';
import { visuals } from './visuals.js';
import { widgets } from '../core/widgets.js';
import { workspace } from './workspace.js';
// environment setup
let LOC = self.location,
SETUP = utils.parseOpt(LOC.search.substring(1)),
SECURE = utilModule.isSecure(LOC.protocol),
LOCAL = self.debug && !SETUP.remote,
EVENT = broker,
FILES = openFiles(new Index(SETUP.d ? SETUP.d[0] : 'kiri'));
// 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
EVENT.on = (topic, listener) => {
EVENT.subscribe(topic, listener);
return EVENT;
};
let busyVal = 0,
isHover = false,
undef = undefined;
// the big kahuna
export const api = {
ajax,
beta,
alerts,
busy: {
val() { return busyVal },
inc() { api.event.emit("busy", ++busyVal) },
dec() { api.event.emit("busy", --busyVal) }
},
catalog: FILES,
client: work,
clip(text) {
navigator.clipboard
.writeText(text)
.catch(err => console.error('Clipboard Error:', err));
},
clone: Object.clone,
color,
conf: settings.conf,
const: { LANG, LOCAL, SETUP, SECURE, SPACE, STACKS, ...consts },
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();
}
},
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: {
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) { alerts.update(clr) },
import: utilModule.loadFile,
settings: settingsUI.trigger_event
},
electron: navigator.userAgent.includes('Electron'),
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: false, // when true fires mouse hover events
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: utilModule.setProgress,
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: utilModule.isSecure,
download: utilModule.download,
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))) }
},
// var: {
// layer_lo: 0,
// layer_hi: 0,
// layer_max: 0
// },
version,
view: {
...viewModule,
...visuals,
snapshot: null
},
visuals,
web,
widgets,
work,
};
// allow widget to straddle client / worker FOR NOW
self.kiri_api = api;

358
src/kiri/app/client.js Normal file
View file

@ -0,0 +1,358 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { api } from './api.js';
import { noop } from '../core/utils.js';
import { codec } from '../core/codec.js';
import { Widget } from '../core/widget.js';
import { newPrint } from '../core/print.js';
// this code runs in kiri's main loop
let debug = self.debug === true,
time = Date.now,
seqid = 1,
syncd = {},
running = {},
worker = null,
minions = false,
restarting = false,
workpath,
poolpath;
/**
* @param {Function} fn name of function in kiri.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
*/
function send(fn, data, onreply, zerocopy) {
let seq = seqid++;
if (onreply) {
// 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) {
console.trace('work send error', {data, error});
}
}
// code is running in the browser / client context
export const client = {
send: send,
setWorkPath(path) {
workpath = path;
return client;
},
setPoolPath(path) {
poolpath = path;
return client;
},
pool: {
start() {
minions = true;
send("pool_start", { url: poolpath }, noop);
},
stop() {
minions = false;
send("pool_stop", {}, noop);
}
},
newWorker() {
if (self.createWorker) {
return self.createWorker();
} else if (self.kiri_work) {
const work = atob(self.kiri_work);
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' });
worker.onerror = (error) => {
console.log({ WORKER_ERROR: error });
error.preventDefault();
};
worker.onmessageerror = (error) => {
console.log({ WORKER_MESSAGE_ERROR: error });
error.preventDefault();
};
return worker;
}
},
isBusy() {
let current = 0;
for (let rec of Object.values(running)) {
if (rec.fn) current++;
}
return current > 0;
},
start() {
client.restart();
},
restart() {
// prevent re-entry from cancel callback
if (restarting) {
return;
}
if (worker) {
worker.terminate();
}
restarting = true;
for (let key in running) {
let rec = running[key];
if (rec.fn) {
rec.fn({error: "cancelled operation"});
}
}
syncd = {};
running = {};
worker = client.newWorker();
client.onmessage = worker.onmessage = (e) => {
let now = time(),
reply = e.data,
record = running[reply.seq],
onreply = record ? record.fn : undefined;
// console.log('client recv', e)
if (reply.done) {
delete running[reply.seq];
}
// calculate and replace recv time
reply.time_recv = now - reply.time_recv;
if (onreply) {
onreply(reply.data, reply);
} else {
console.log({unexpected_reply: reply});
}
};
// restart minions if they were running
if (minions) {
client.pool.start();
}
restarting = false;
},
config(obj) {
send("config", obj, noop);
},
clear() {
syncd = {};
send("clear", {}, noop);
},
snap(data) {
send("snap", data, noop);
},
png(data, ondone) {
send("png", data, ondone);
},
// widget sync
sync(widgets) {
if (!widgets) {
widgets = api.widgets.all();
}
// sync any widget that has changed
for (let widget of widgets.filter(w => w.modified || !syncd[w.id])) {
syncd[widget.id] = true;
let vertices = widget.getGeoVertices();
send("sync", {
id: widget.id,
meta: widget.meta,
group: widget.group.id,
track: widget.track,
vertices,
position: widget.mesh.position,
}, done => {
widget.modified = false;
});
}
},
// for belt / angled printing
rotate(settings, callback) {
send("rotate", { settings }, reply => {
if (reply.group) {
// collect post-rotation data for slice/preview renders
for (let widget of Widget.Groups.forid(reply.group)) {
widget.belt = reply.belt;
}
} else if (callback) {
callback();
}
});
},
// called once for each widget
slice(settings, widget, callback) {
send("slice", {
id: widget.id,
anno: widget.annotations(),
settings: settings
}, reply => {
callback(reply);
if (reply.done || reply.error) {
// in belt mode, slicing modifies a widget and requires re-sync
if (settings.device.bedBelt) {
widget.modified = true;
}
}
});
},
// called once after each widget is sliced
// performs merged actions that consider all widget slice data
sliceAll(settings, callback) {
send("sliceAll", { settings }, callback);
},
// turn widget slices into unified path output
prepare(settings, update, done) {
send("prepare", { settings }, reply => {
if (reply.progress) {
update(reply.progress, reply.message, reply.layer);
}
if (reply.done) {
done(reply.output, reply.maxSpeed, reply.minSpeed);
}
if (reply.error) {
done(reply);
}
});
},
// convert path output to gcode or other target file output
export(settings, online, ondone) {
send("export", { settings }, reply => {
if (reply.line) {
online(reply.line);
}
if (reply.done) {
ondone(reply.output);
}
if (reply.error) {
ondone(null, reply.error);
}
if (reply.debug) {
api.event.emit("export.debug", reply.debug);
}
});
},
colors(colors, max, done) {
send("colors", { colors, max }, reply => {
done(reply);
});
},
parse(args, progress, done) {
// have to do this client side because DOMParser is not in workers
if (args.type === 'svg') {
const { settings, code, type } = args;
const origin = settings.origin;
const offset = {
x: origin.x,
y: -origin.y,
z: origin.z
};
const output = newPrint().parseSVG(code, offset);
send("parse_svg", output, reply => {
if (reply.parsed) {
done(codec.decode(reply.parsed));
}
});
return;
}
send("parse", args, reply => {
if (reply.progress) {
progress(reply.progress);
}
if (reply.parsed) {
done(codec.decode(reply.parsed), reply.maxSpeed, reply.minSpeed);
}
});
},
image2mesh(data, progress, output) {
send("image2mesh", data, reply => {
if (reply.progress) {
progress(reply.progress);
}
if (reply.vertices) {
output(reply.vertices);
}
}, [ data.png ]);
},
gerber2mesh(data, progress, output) {
send('gerber2mesh', data, reply => {
if (reply.progress) {
progress(reply.progress);
}
if (reply.vertices) {
output(reply.vertices);
}
});
},
zip(files, progress, output) {
send("zip", {files}, reply => {
if (reply.percent !== undefined) {
progress(reply);
} else {
output(reply);
}
});
},
wasm(enable) {
send("wasm", {enable}, reply => {
// console.log({wasm_worker_said: reply});
});
},
putCache(key, data) {
console.log({ putCache: key, data });
send("putCache", {key, data}, reply => {
// console.log({ putCache_reply: reply });
});
},
clearCache() {
send("clearCache", {}, reply => {
// console.log({ clearCache_reply: reply });
});
}
};
self.kiri_client = client;
export default client;

879
src/kiri/app/component.js Normal file
View file

@ -0,0 +1,879 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { $ } from '../../moto/webui.js';
import { api } from './api.js';
let DOC = self.document,
inputAction = null,
lastAddTo = null,
lastGroup = null,
lastDiv = null,
addTo = null,
bindTo = null,
groups = {},
groupSticky = false,
groupName = undefined,
heads = {}, // hideable group heads (clickable label)
hidden = {}, // hidden groups (by name)
hasModes = [],
setters = [],
lastMode = null,
lastExpert = true,
prefix = "tab",
units = 1,
lastChange = null,
lastBtn = null,
lastTxt = null,
lastPop = null;
export const UI = {
prefix: function(pre) { prefix = pre; return UI },
inputAction: function(fn) { inputAction = fn; return UI },
lastChange: function() { return lastChange },
checkpoint,
restore,
refresh,
setHidden,
setMode,
bound,
toInt,
toFloat,
toFloatArray,
toDegsFloat,
isSticky,
setSticky,
newBoolean,
newButton,
newBlank,
newDiv,
newElement,
newExpand,
endExpand,
newGCode,
newGroup,
newLabel,
newInput,
newValue,
newRange,
newRow,
newSelect,
newText,
setGroup,
addUnits,
setUnits,
confirm,
prompt,
alert,
onBlur,
setEnabled(el, bool) {
if (bool) {
el.removeAttribute('disabled');
} else {
el.setAttribute('disabled','');
}
},
setVisible(el, bool) {
UI.setClass(el, 'hide', !bool);
},
setClass(el, clazz, bool) {
if (bool) {
el.classList.add(clazz);
} else {
el.classList.remove(clazz);
}
}
};
function setHidden(map) {
hidden = map;
refresh();
for (let ctrl of Object.values(heads)) {
ctrl.update();
}
}
function onBlur(obj, fn) {
if (Array.isArray(obj)) {
for (let o of obj) onBlur(o, fn);
return;
}
obj.addEventListener('blur', fn);
}
function alert(message) {
return confirm(message, {ok:true});
}
function prompt(message, value) {
return confirm(message, {ok:true, cancel:undefined}, value);
}
function confirm(message, buttons, input, opt = {}) {
return new Promise((resolve, reject) => {
let { feature } = api;
let onkey_save = feature.on_key;
feature.on_key = key => {
// console.log({ eat_key: key });
return true;
};
let dialog = $('dialog');
let btns = buttons || {
"yes": true,
"no": false
};
let rnd = Date.now().toString(36);
let html = [
`<div class="confirm f-col a-stretch" style="padding:5px !important">`
];
if (message) {
html.push(`<label style="user-select:text">${message}</label>`);
}
if (opt.pre) {
html = opt.pre.appendAll(html);
}
let iid;
if (Array.isArray(input)) {
iid = `confirm-input-${rnd}`;
html.append(`<div><textarea rows="15" cols="40" class="grow" type="text" spellcheck="false" id="${iid}"></textarea></div>`);
} else if (input !== undefined) {
iid = `confirm-input-${rnd}`;
html.append(`<div><input class="grow" type="text" spellcheck="false" id="${iid}"/></div>`);
}
html.append(`<div class="f-row j-end">`);
Object.entries(btns).forEach((row,i) => {
html.append(`<button id="confirm-${i}-${rnd}">${row[0]}</button>`);
});
html.append(`</div></div>`);
if (opt.post) {
html.appendAll(opt.post);
}
dialog.innerHTML = html.join('');
function done(value) {
dialog.close();
feature.on_key = onkey_save;
if (value !== undefined) {
setTimeout(() => { resolve(value) }, 150);
}
}
if (iid) {
let array = Array.isArray(input);
iid = $(iid);
iid.value = array ? input.join('\n') : input;
if (!array)
iid.onkeypress = (ev) => {
if (ev.key === 'Enter' || ev.charCode === 13) {
done(ev.target.value);
}
};
}
Object.entries(btns).forEach((row,i) => {
$(`confirm-${i}-${rnd}`).onclick = (ev) => {
let value = iid && row[1] ? iid.value : row[1];
ev.preventDefault();
ev.stopPropagation();
done(value);
}
});
setTimeout(() => {
dialog.showModal();
if (iid) {
iid.focus();
iid.selectionStart = 0;
iid.selectionEnd = iid.value.length;
}
}, 150);
});
}
function refresh() {
setMode(lastMode);
setters.forEach(input => {
if (input.setv) {
input.setv(input.real);
}
});
}
function setMode(mode) {
lastMode = mode;
hasModes.forEach(div => div.setMode(mode));
}
function checkpoint(at) {
return { addTo: at || addTo, lastDiv: at || lastDiv, lastGroup, groupName };
}
function restore(opt = {}) {
addTo = opt.addTo || addTo;
bindTo = opt.bindTo || null;
lastDiv = opt.lastDiv || lastDiv;
lastGroup = opt.lastGroup || lastGroup;
groupName = opt.groupName || groupName;
}
// at present only used by the layers popup menu
function setGroup(div) {
addTo = lastDiv = div;
groupName = undefined;
lastGroup = [];
return div;
}
function newElement(type, opt = {}) {
let el = DOC.createElement(type);
if (opt.id) {
el.setAttribute("id", opt.id);
}
if (opt.class) {
for (let cl of opt.class.split(' ')) {
el.classList.add(cl);
}
}
if (opt.attr) {
for (let [key, val] of Object.entries(opt.attr)) {
el.setAttribute(key, val);
}
}
return el;
}
function newGroup(label, div, opt = {}) {
lastDiv = div = (div || lastDiv);
let group = opt.group || label,
row = DOC.createElement('div'),
dbkey = `beta-${prefix}-show-${group}`,
link;
if (opt.class) {
opt.class.split(' ').forEach(ce => {
row.classList.add(ce);
});
} else {
row.setAttribute("class", "set-header");
}
if (div && opt.driven) {
addModeControls(div, opt);
}
addTo = lastDiv;
div.appendChild(row);
if (label) {
link = DOC.createElement('a');
link.appendChild(DOC.createTextNode(label));
row.appendChild(link);
}
addModeControls(row, opt);
lastGroup = groups[group] = [];
lastGroup.key = dbkey;
groupName = group;
if (opt.hideable) {
let pad = DOC.createElement('i');
let arr = DOC.createElement('span');
pad.setAttribute('class','grow');
row.appendChild(pad);
row.appendChild(arr);
const ctrl = heads[group] = {
row,
arr,
update() {
if (!hidden[group]) {
arr.innerHTML = '<i class="fa-solid fa-caret-down"></i>';
row.classList.add('hidden');
} else {
arr.innerHTML = '<i class="fa-solid fa-caret-up"></i>';
row.classList.remove('hidden');
}
}
};
row.onclick = () => {
hidden[group] = !hidden[group];
refresh();
ctrl.update();
};
}
return row;
}
function addCollapsableElement(parent, options = {}) {
let row = newDiv(options);
if (parent) parent.appendChild(row);
if (lastGroup) lastGroup.push(row);
return row;
}
function bound(low,high) {
return function(v) {
if (isNaN(v)) return low;
return v < low ? low : v > high ? high : v;
};
}
function toInt() {
let nv = this.value !== '' ? parseInt(this.value) : null;
if (isNaN(nv)) nv = 0;
if (nv !== null && this.bound) nv = this.bound(nv);
this.value = nv;
if (this.setv) {
return this.real = Math.round(nv * units);
};
return nv;
}
function toFloat() {
let nv = this.value !== '' ? parseFloat(this.value) : null;
if (nv !== null && this.bound) nv = this.bound(nv);
this.value = nv;
if (this.setv) {
return this.setv(nv * units);
}
return nv;
}
function toFloatArray() {
let nv = this.value !== '' ? this.value.split(',').map(v => parseFloat(v)) : null;
console.log({ toFloatArray: nv });
if (this.bound) nv = nv.map(v => this.bound(v));
this.array = nv;
return nv;
}
function toDegsFloat(){
let nv = this.value !== '' ? parseFloat(this.value) : null;
if (nv !== null && this.bound) nv = this.bound(nv);
nv = (nv+360)% 360; // bound the val to 0-359.99
this.value = nv;
if (this.setv) {
return this.setv(nv);
}
return nv;
}
function raw() {
return this.value !== '' ? this.value : null;
}
function setUnits(v) {
if (v !== units) {
units = v;
refresh();
}
}
function newLabel(text, opt = {}) {
let label = DOC.createElement('label');
label.appendChild(DOC.createTextNode(text));
label.setAttribute("class", "noselect");
if (opt.class) opt.class.split(' ').forEach(cl => {
label.classList.add(cl);
})
return label;
}
function newValue(size = 6, opt = {}) {
let value = DOC.createElement('input');
value.setAttribute("size", size);
value.setAttribute("readonly", '');
value.setAttribute("class", "nooutline noselect");
if (opt.class) opt.class.split(' ').forEach(cl => {
value.classList.add(cl);
})
return value;
}
function addId(el, opt = {}) {
if (opt.id) {
el.setAttribute("id", opt.id);
}
}
function safecall(fn) {
try {
return fn();
} catch (e) {
// console.log({ safecall_error: e });
return false;
}
}
function addModeControls(el, opt = {}) {
el.__opt = opt;
el.showMe = function() {
if (opt.trace) console.log({ showMe: el });
el.classList.remove('hide');
};
el.hideMe = function() {
if (opt.trace) console.log({ hideMe: el });
el.classList.add('hide');
};
el.setVisible = function(show) {
if (opt.trace) console.log({ setVisible: show });
if (show) el.showMe();
else el.hideMe();
};
el.setMode = function(mode) {
let hidn = hidden[el._group] === true;
let xprt = opt.expert === undefined || (opt.expert === lastExpert);
let show = opt.show ? safecall(opt.show) : true;
let disp = opt.visible ? opt.visible() : true;
let hmod = el.hasMode(mode);
if (opt.trace) console.log({ setMode: mode, xprt, show, disp, hmod, modes:el.modes });
if (opt.manual) return;
el.setVisible(!hidn && hmod && show && xprt && disp);
}
el.hasMode = function(mode) {
return (el.modes.length === 0) ||
(el.modes.contains && el.modes.contains(mode)) ||
(el.modes === mode);
}
el.modes = opt.modes || [];
hasModes.push(el);
}
function newDiv(opt = {}) {
let div = DOC.createElement(opt.tag || 'div');
addModeControls(div, opt);
(opt.addto || addTo).appendChild(div);
if (opt.addto) lastDiv = addTo = div;
if (opt.class) div.setAttribute('class', opt.class);
if (opt.content) div.innerHTML = opt.content;
if (opt.group !== false) {
lastGroup?.push(div);
div._group = groupName;
} else {
lastGroup = undefined;
groupName = undefined;
}
return div;
}
function newExpand(label, opt = {}) {
let div = DOC.createElement('details');
div.setAttribute('class', opt.class || 'f-col');
if (opt.open) div.setAttribute('open', true);
addModeControls(div, opt);
let summary = DOC.createElement('summary');
summary.setAttribute('class', opt.class || 'var-row');
summary.innerHTML = `<label>${label}</label>`;
div.appendChild( summary );
div.collapse = () => {
div.removeAttribute('open');
};
lastAddTo = addTo;
addTo.appendChild(div);
addTo = div;
return div;
}
function endExpand() {
addTo = lastAddTo;
return addTo;
}
function isSticky() {
return groupSticky;
}
function setSticky(bool) {
groupSticky = bool;
}
function newGCode(label, options) {
let opt = options || {},
btn = DOC.createElement("button"),
txt = DOC.createElement("textarea"),
area = opt.area;
txt.setAttribute("wrap", "off");
txt.setAttribute("spellcheck", "false");
txt.setAttribute("style", "resize: none");
txt.onblur = bindTo || inputAction;
txt.button = btn;
btn.setAttribute("class", "basis-50");
btn.appendChild(DOC.createTextNode(label));
btn.setAttribute("title", opt.title || undefined);
btn.onclick = function(ev) {
ev.stopPropagation();
if (ev.target === txt) {
// drop clicks on TextArea
ev.target.focus();
} else {
let fc = area.firstChild;
if (fc) area.removeChild(fc);
area.appendChild(txt);
txt.scrollTop = 0;
txt.scrollLeft = 0;
txt.selectionEnd = 0;
let rows = txt.value.split('\n');
let cols = 0;
rows.forEach(row => {
cols = Math.max(cols, row.length);
});
let showing = btn === lastBtn;
if (lastTxt) {
lastTxt.classList.remove('txt-sel');
}
if (lastBtn) {
lastBtn.classList.remove('btn-sel');
}
if (!showing) {
btn.classList.add('btn-sel');
lastTxt = btn;
lastBtn = btn;
txt.focus();
} else {
inputAction();
}
}
};
addModeControls(btn, opt);
return txt;
}
function newText(label, options) {
let opt = options || {},
inline = opt.row,
row = inline ? lastDiv : newDiv(options),
btn = DOC.createElement("button"),
pop = DOC.createElement("div"),
txt = DOC.createElement("textarea"),
area = opt.area;
txt.setAttribute("wrap", "off");
txt.setAttribute("spellcheck", "false");
txt.setAttribute("style", "resize: none");
txt.onblur = inputAction;
btn.appendChild(DOC.createTextNode(inline ? label : "edit"));
btn.onclick = function(ev) {
ev.stopPropagation();
if (ev.target === txt) {
// drop clicks on TextArea
ev.target.focus();
} else {
let fc = area.firstChild;
if (fc) area.removeChild(fc);
area.appendChild(txt);
// first time, button click / show
btn.parentNode.onclick = btn.onclick;
txt.scrollTop = 0;
txt.scrollLeft = 0;
txt.selectionEnd = 0;
let rows = txt.value.split('\n');
let cols = 0;
rows.forEach(row => {
cols = Math.max(cols, row.length);
});
let showing = pop === lastPop;
if (lastBtn) {
lastBtn.classList.remove('btn-sel');
}
if (lastTxt) {
lastTxt.classList.remove('txt-sel');
}
if (!showing) {
row.classList.add('txt-sel');
pop.style.display = "flex";
lastPop = pop;
lastTxt = row;
txt.focus();
} else {
inputAction();
}
}
};
addModeControls(btn, opt);
addId(btn, opt);
if (!inline) {
row.appendChild(newLabel(label));
row.setAttribute("class", "var-row");
}
row.appendChild(btn);
if (opt.title) row.setAttribute("title", options.title);
if (row.setVisible) {
btn.setVisible = row.setVisible;
}
return txt;
}
function newInput(label, opt = {}) {
let row = newDiv(opt),
hide = opt.hide,
size = opt.size ?? 5,
height = opt.height || 0,
action = opt.action || bindTo || inputAction,
ip = height > 1 ? DOC.createElement('textarea') : DOC.createElement('input');
row.appendChild(newLabel(label));
row.appendChild(ip);
row.setAttribute("class", opt.class || "var-row");
if (height > 1) {
ip.setAttribute("cols", size);
ip.setAttribute("rows", height);
ip.setAttribute("wrap", "off");
} else {
if (Number.isInteger(size)) {
ip.setAttribute("size", size);
} else {
ip.setAttribute("style", `width:${size}`);
}
}
ip.setAttribute("type", "text");
ip.setAttribute("spellcheck", "false");
row.style.display = hide ? 'none' : '';
if (opt.disabled) ip.setAttribute("disabled", "true");
if (opt.title) row.setAttribute("title", opt.title);
if (opt.id) ip.setAttribute("id", opt.id);
if (opt.convert) ip.convert = opt.convert.bind(ip);
if (opt.bound) ip.bound = opt.bound;
if (opt.action) action = opt.action;
ip.addEventListener('focus', function(event) {
setSticky(true);
});
if (action) {
ip.addEventListener('keydown', function(event) {
let key = event.key;
if (
opt.text ||
(key >= '0' && key <= '9') ||
key === '.' ||
key === '-' ||
key === 'Backspace' ||
key === 'Delete' ||
key === 'ArrowLeft' ||
key === 'ArrowRight' ||
key === 'Tab' ||
event.metaKey ||
event.ctrlKey ||
(key === ',' && options.comma)
) {
return;
}
event.preventDefault();
event.stopPropagation();
});
ip.addEventListener('keyup', function(event) {
if (event.keyCode === 13) {
ip.blur();
}
});
ip.addEventListener('blur', function(event) {
setSticky(false);
lastChange = ip;
action(event);
if (opt.trigger) {
refresh(opt.trigger === 1 || opt.trigger === true);
}
});
if (opt.units) {
addUnits(ip, opt.round || 3);
}
}
if (!ip.convert) ip.convert = raw.bind(ip);
ip.setVisible = row.setVisible;
return ip;
}
function addUnits(input, round) {
setters.push(input);
input.setv = function(value) {
if (typeof(value) === 'number') {
input.real = value;
input.value = (value / units).round(round);
} else {
input.value = value;
}
// console.log({ value }, input.real, input.value );
return input.real;
};
return input;
}
function newRange(label, options) {
let row = newDiv(options),
ip = DOC.createElement('input'),
hide = options && options.hide,
action = bindTo || inputAction;
if (label) row.appendChild(newLabel(label));
row.appendChild(ip);
row.setAttribute("class", "var-row");
ip.setAttribute("type", "range");
ip.setAttribute("min", (options && options.min ? options.min : 0));
ip.setAttribute("max", (options && options.max ? options.max : 100));
ip.setAttribute("value", 0);
row.style.display = hide ? 'none' : '';
if (options) {
if (options.title) {
ip.setAttribute("title", options.title);
row.setAttribute("title", options.title);
}
if (options.action) action = options.action;
}
ip.setVisible = row.setVisible;
return ip;
}
function newSelect(label, options = {}, source) {
let row = newDiv(options),
ip = DOC.createElement('select'),
hide = options && options.hide,
action = bindTo || inputAction;
row.appendChild(newLabel(label));
row.appendChild(ip);
if (Array.isArray(source)) {
ip._source = source;
} else {
row.setAttribute("source", source || "tools");
}
row.setAttribute("class", "var-row");
row.style.display = hide ? 'none' : '';
if (options.id) ip.setAttribute("id", options.id);
if (options.convert) ip.convert = options.convert.bind(ip);
if (options.disabled) ip.setAttribute("disabled", "true");
if (options.title) row.setAttribute("title", options.title);
if (options.action) action = options.action;
ip.setVisible = row.setVisible;
ip.onchange = function(ev) {
lastChange = ip;
action();
if (options.trigger) {
refresh();
}
if (options.post) {
options.post();
}
};
ip.onclick = (ev) => {
groupSticky = true;
};
// because firefox
ip.onmouseenter = (ev) => {
groupSticky = true;
};
return ip;
}
function newBoolean(label, action = bindTo, opt = {}) {
let row = newDiv(opt),
ip = DOC.createElement('input'),
hide = opt.hide;
if (label) {
row.appendChild(newLabel(label));
}
row.appendChild(ip);
row.setAttribute("class", "var-row");
row.style.display = hide ? 'none' : '';
ip.setAttribute("type", "checkbox");
ip.checked = false;
if (opt.disabled) {
ip.setAttribute("disabled", "true");
}
if (opt.title) {
ip.setAttribute("title", opt.title);
row.setAttribute("title", opt.title);
}
if (action) {
ip.onclick = function(ev) {
action(ip);
if (opt.trigger) {
refresh();
}
};
}
ip.setVisible = row.setVisible;
return ip;
}
function newBlank(options) {
let opt = options || {},
row = newDiv(opt),
hide = opt.hide;
row.isBlank = true;
row.style.display = hide ? 'none' : '';
if (!opt.driven) {
row.setAttribute("class", "var-row");
}
if (opt.class) {
opt.class.split(' ').forEach(ce => {
row.classList.add(ce);
});
}
return row;
}
// unlike other elements, does not auto-add to a row
function newButton(label, action, opt = {}) {
let b = DOC.createElement('button');
b.onclick = function() {
switch (typeof action) {
case "string":
api.event.emit(action);
break;
case "function":
action(...arguments);
break;
}
};
if (opt.class) {
opt.class.split(' ').forEach(ce => {
b.classList.add(ce);
});
}
if (opt.icon) {
let d = DOC.createElement('div');
d.innerHTML = opt.icon;
b.appendChild(d);
}
if (opt.title) {
b.setAttribute('title', opt.title);
}
if (label) {
b.appendChild(DOC.createTextNode(label));
}
addModeControls(b, opt);
addId(b, opt);
return b;
}
function newRow(children, options) {
let row = addCollapsableElement((options && options.noadd) ? null : addTo);
if (children) children.forEach(function (c) { row.appendChild(c) });
addModeControls(row, options);
if (options && options.class) {
options.class.split(' ').forEach(ce => {
row.classList.add(ce);
});
}
return row;
}

File diff suppressed because it is too large Load diff

View file

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

View file

@ -0,0 +1,816 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
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 '../../core/consts.js';
import { space } from '../../../moto/space.js';
import { local } from '../../../data/local.js';
import { utils } from '../../core/utils.js';
import { version } from '../../../moto/license.js';
import { JSZip } from '../../../ext/jszip-esm.js';
const { areEqual, ls2o, js2o } = utils;
const { COLOR } = consts;
const { clone } = Object;
const localFilterKey ='kiri-gcode-filters';
const localFilters = js2o(local.getItem(localFilterKey)) || [];
let settings = clone(conf.template);
function normalize(set) {
return conf.normalize(set);
}
function getSettings() {
return settings;
}
function putSettings(newset) {
settings = normalize(newset);
saveSettings();
api.space.restore(null, true);
}
function saveSettings() {
const { ui } = api;
const view = space.view.save();
if (view.left || view.up) {
settings.controller.view = view;
}
const mode = settings.mode;
settings.sproc[mode].default = settings.process;
settings.sproc[mode][settings.process.processName] = settings.process;
// moved to init.onBooleanClick() -- delete later after confirming no side-effects
// settings.device.bedBelt = ui.deviceBelt.checked;
// settings.device.bedRound = ui.deviceRound.checked;
// settings.device.originCenter = ui.deviceOrigin.checked || ui.deviceRound.checked;
// settings.device.fwRetract = ui.fwRetract.checked;
local.setItem('ws-settings', JSON.stringify(settings));
api.event.emit('settings.saved', settings);
}
function loadSettings(evt, named) {
let mode = settings.mode,
name = evt ? evt.target.getAttribute("load") : named || api.process.get() || "default",
load = settings.sproc[mode][name];
if (!load) return;
// cloning loaded process into settings requires user to save
// process before switching devices or risk losing any changes
settings.process = clone(load);
// update process name
settings.process.processName = name;
// save named process with the current device
settings.devproc[api.device.get()] = name;
// preserve name of last library loaded
if (name !== 'default') {
settings.cproc[mode] = name;
}
// allow mode driver to take any necessary actions
api.event.emit("settings.load", settings);
// update UI fields to reflect current settings
api.conf.update_fields();
api.conf.update();
// publish event when loading from ui event
if (evt) api.event.settings();
// update hidden map from settings
api.uc.setHidden(settings.hidden);
}
function showSettings() {
api.settings.sync.get().then(() => {
api.dialog.update_process_list();
api.modal.show("saves");
api.ui.settingsName.focus();
});
}
function updateSettings(opt = {}) {
const { controller, device, process, mode, sproc, cproc } = settings;
const { uc } = api;
const changes = {};
updateSettingsFromFields(controller, undefined, changes);
switch (controller.units) {
case 'mm': uc.setUnits(1); break;
case 'in': uc.setUnits(25.4); break;
}
// limit update to controller fields (ui prefs dialog)
if (opt.controller) {
return;
}
updateSettingsFromFields(device, undefined, changes);
// range-specific values
if (settings.mode === 'FDM' && api.view.is_slice()) {
let changes = {};
let values = process;
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) {
values = Object.clone(process);
add = true;
}
// collect changes to pass to range updates
updateSettingsFromFields(values, undefined, changes);
if (range) {
updateRange(range.lo, range.hi, changes, add);
}
} else {
updateSettingsFromFields(process, undefined, changes);
}
if (device.extruders && device.extruders[device.internal]) {
updateSettingsFromFields(device.extruders[device.internal]);
}
// invalidate slice, preview, export on settings changes
if (Object.keys(changes).length) {
api.function.clear_progress();
}
api.conf.save();
let compare = sproc[mode][cproc[mode]];
let same = true;
if (compare)
for (let [key, val] of Object.entries(compare).filter(v => v[0] !== 'processName')) {
let tval = process[key];
// outputLoopLayers misbehaving and setting null on empty
if (val === '' && tval == null) {
continue;
}
if (Array.isArray(tval) && Array.isArray(val)) {
if (JSON.stringify(tval) == JSON.stringify(val)) {
continue;
}
}
if (tval != val) {
// console.log(key, 'expected', val, 'got', tval);
same = false;
}
}
$('mode-device').innerText = device.deviceName;
$('mode-profile').innerText = `${cproc[mode]}${same ? '' : ' *'}`;
}
function updateSettingsFromFields(setrec, uirec = api.ui, changes) {
if (!setrec) {
return console.trace("missing scope");
}
let lastChange = api.uc.lastChange();
// for each key in setrec object
for (let key in setrec) {
if (!setrec.hasOwnProperty(key)) {
// console.log({no_setrec: key});
continue;
}
if (!uirec.hasOwnProperty(key)) {
// console.log({no_uirec: key});
continue;
}
let nval = null, uie = uirec[key];
// skip empty UI values
if (!uie || uie === '') {
// console.log({uie_empty: key});
continue;
}
if (uie.type === 'text') {
nval = uirec[key].convert();
} else if (uie.type === 'checkbox') {
nval = uirec[key].checked;
} else if (uie.type === 'select-one') {
if (uie.selectedIndex >= 0) {
nval = uie.options[uie.selectedIndex].value;
let src = uie.parentNode.getAttribute('source');
if (src === 'tools' || src == 'extruders') {
nval = parseInt(nval);
}
} else {
nval = setrec[key];
}
} else if (uie.type === 'textarea') {
nval = uie.value.trim().split('\n').filter(v => v !== '');
} else {
continue;
}
if (lastChange === uie) {
if (changes) changes[key] = nval;
}
if (!areEqual(setrec[key], nval)) {
setrec[key] = nval;
if (changes) {
changes[key] = nval;
}
}
}
return settings;
}
// given a settings region, update values of matching bound UI fields
function updateFieldsFromSettings(setrec, uirec = api.ui, opt = {}) {
if (!setrec) {
updateFieldsFromSettings(settings.device);
updateFieldsFromSettings(settings.process);
updateFieldsFromSettings(settings.controller);
updateExtruderFields(settings.device);
return;
}
if (opt.trace) {
console.log({ setrec, uirec });
}
for (let key in setrec) {
if (!setrec.hasOwnProperty(key)) {
continue;
}
let val = setrec[key];
if (!uirec.hasOwnProperty(key)) {
continue;
}
let uie = uirec[key],
typ = uie ? uie.type : null;
if (opt.trace) {
console.log({ key, uie, typ, val });
}
if (typ === 'text') {
if (uie.setv) {
uie.setv(val);
} else {
uie.value = val;
}
} else if (typ === 'checkbox') {
uie.checked = val;
} else if (typ === 'select-one') {
uie.innerHTML = '';
let source = uie.parentNode.getAttribute('source'),
list = uie._source || settings[source] || api.lists[source],
chosen = null;
if (list) list.forEach(function(el, index) {
let id = el.id || el.name;
let ev = el.value || id;
if (val == id) {
chosen = index;
}
let opt = document.createElement('option');
opt.appendChild(document.createTextNode(el.name));
opt.setAttribute('value', ev);
uie.appendChild(opt);
});
if (chosen) {
uie.selectedIndex = chosen;
}
} else if (typ === 'textarea') {
if (Array.isArray(val)) {
uie.value = val.join('\n');
} else {
uie.value = '';
}
} else if (opt.trace) {
console.log('skipped', { key, val, typ });
}
}
}
function updateExtruderFields(device) {
const { ui } = api;
const { current: LANG } = api.const.LANG;
if (device.extruders && device.extruders[device.internal]) {
updateFieldsFromSettings(device.extruders[device.internal]);
ui.extruder.innerHTML =
`${LANG.dv_gr_ext}<label class='grow'></label>${device.internal+1} of ${device.extruders.length}`;
ui.extPrev.disabled = device.internal === 0;
ui.extPrev.onclick = function() {
device.internal--;
updateExtruderFields(device);
};
ui.extNext.disabled = device.internal === device.extruders.length - 1;
ui.extNext.onclick = function() {
device.internal++;
updateExtruderFields(device);
};
ui.extDel.disabled = ui.extDel.disabled || device.extruders.length < 2;
ui.extDel.onclick = function() {
device.extruders.splice(device.internal,1);
device.internal = Math.min(device.internal, device.extruders.length-1);
updateExtruderFields(device);
};
ui.extAdd.onclick = function() {
let copy = clone(device.extruders[device.internal]);
device.extruders.push(copy);
device.internal = device.extruders.length - 1;
updateExtruderFields(device);
};
api.uc.setClass($('tool-nozzle'), 'hide', false);
h.bind($('ft-nozzle'), device.extruders.map((d,i) => h.div([
h.button({
_: `extruder ${i}`,
onclick() {
api.selection.for_widgets(w => {
w.anno.extruder = i;
w.setColor(
api.selection.contains(w) ?
COLOR.selected : COLOR.deselected,
undefined,
false);
});
}
}),
h.div({
class: "splat",
style: `background-color: #${COLOR.deselected[i].toString(16)}`
})
])));
} else {
api.uc.setClass($('tool-nozzle'), 'hide', true);
}
}
function getOverlappingRanges(lo, hi) {
let ranges = [];
for (let range of settings.process.ranges || []) {
let in_lo = range.lo >= lo && range.lo <= hi;
let in_hi = range.hi >= lo && range.hi <= hi;
if (in_lo || in_hi) {
ranges.push(range);
}
}
return ranges;
}
// set process override values for a range
function updateRange(lo, hi, values, add) {
let ranges = settings.process.ranges;
let slices = {};
let min = lo;
let max = hi;
// special case for belt loops which should not be flattened
if (values.outputLoops) {
ranges.push({
lo, hi, fields: values
});
api.conf.update_fields(settings.process);
api.show.alert("update ranges", 2);
api.event.emit("range.updates", ranges);
return;
}
// just remove values from matching ranges
if (!add) {
for (let range of getOverlappingRanges(lo, hi)) {
for (let key of Object.keys(values)) {
delete range.fields[key];
}
if (Object.keys(range.fields).length === 0) {
let pos = ranges.indexOf(range);
if (pos >= 0) {
ranges.splice(pos,1);
}
}
}
api.event.emit("range.updates", ranges);
return;
}
// set aside belt loops and re-append later
// since we do not want to collapse/merge loops
let exclude = ranges.filter(r => r.fields.outputLoops);
ranges = ranges.filter(r => !r.fields.outputLoops);
// flatten ranges
ranges.push({lo, hi, fields: values});
for (let range of ranges) {
min = Math.min(range.lo, min);
max = Math.max(range.hi, max);
for (let i=range.lo; i<=range.hi; i++) {
let slice = slices[i];
if (!slice) {
slice = slices[i] = {};
}
for (let [key,val] of Object.entries(range.fields)) {
slice[key] = val;
}
}
}
// merge contiguous matching ranges
ranges = settings.process.ranges = [];
let range;
for (let i=min; i<=max; i++) {
let slice = slices[i];
if (slice && !range) {
range = {lo: i, hi: i, fields: slice};
} else if (slice && range && areEqual(range.fields, slice)) {
range.hi = i;
} else if (range) {
ranges.push(range);
if (slice) {
range = {lo: i, hi: i, fields: slice};
} else {
range = undefined;
}
}
}
ranges.push(range);
ranges.appendAll(exclude);
api.conf.update_fields(settings.process);
api.show.alert("update ranges", 2);
api.event.emit("range.updates", ranges);
}
let overrides = {};
// updates editable fields that are range dependent
function updateFieldsFromRange() {
if (true) return;
if (settings.mode !== 'FDM' || !api.view.is_slice() || !settings.process.ranges) {
let okeys = Object.keys(overrides);
if (okeys.length) {
api.conf.update_fields(overrides);
overrides = {};
}
return;
}
let match = 0;
let values = {};
let restores = Object.clone(overrides);
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];
delete restores[key];
match++;
}
}
if (match) {
api.conf.update_fields(values);
}
let rkeys = Object.keys(restores);
if (rkeys.length) {
api.conf.update_fields(restores);
for (let key of rkeys) {
delete overrides[key];
}
}
api.uc.refresh();
}
function restoreSettings(save) {
const widgets = api.widgets.all();
const newset = ls2o('ws-settings') || settings;
// extract legacy widget annotations into widgets
if (newset.widget) {
for (let id of Object.keys(newset.widget)) {
let anno = newset.widget[id];
let wid = widgets.filter(w => w.id === id)[0];
if (wid && anno) {
wid.anno = anno;
console.log('transfer settings annotations to widget', id);
wid.saveState();
} else {
console.log('missing widget for annotations', id);
}
delete newset.widget[id];
}
}
settings = normalize(newset);
// override camera from settings
if (settings.controller.view) {
local.removeItem('ws-camera');
}
// merge custom filters from localstorage into settings
localFilters.forEach(function(fname) {
let fkey = "gcode-filter-"+fname, ov = ls2o(fkey);
if (ov) settings.devices[fname] = ov;
local.removeItem(fkey)
});
local.removeItem(localFilterKey);
// save updated settings
if (save) api.conf.save();
return newset;
}
function settingsExport(opts = {}) {
const widgets = api.widgets.all();
const note = opts.node || undefined;
const shot = opts.work || opts.screen ? space.screenshot() : undefined;
const work = opts.work ? codec.encode(widgets,{_json_:true}) : undefined;
const view = opts.work ? space.view.save() : undefined;
const setn = Object.clone(settings);
// stuff in legacy annotations for re-import
for (let w of widgets) {
setn.widget[w.id] = w.anno;
}
const xprt = {
settings: setn,
version: version,
screen: shot,
space: space.info,
note: note,
work: work,
view: view,
moto: moto.id,
init: local.getItem('kiri-init'),
time: Date.now()
};
return opts.clear ? xprt : api.util.b64enc(xprt);
}
function settingsImport(data, ask) {
const { uc, ui } = api;
if (typeof(data) === 'string') {
try {
data = data.charAt(0) === '{' ? JSON.parse(data) : api.util.b64dec(data);
} catch (e) {
uc.alert('invalid import format');
console.log('data',data,{type: typeof data},e);
return;
}
}
if (api.const.LOCAL) console.log('import', data);
let isSettings = (data.settings && data.time);
let isProcess = (data.process && data.time && data.mode && data.name);
let isDevice = (data.device && data.time);
let isTools = (data.tools && data.time)
let isWork = (data.work);
if (!isSettings && !isDevice && !isProcess && !isTools) {
uc.alert('invalid settings or device format');
console.log('data',data);
return;
}
function doit() {
function devset() {
settings.devices[data.device] = data.code;
settings.devproc[data.device] = data.name;
api.show.devices();
}
function procset() {
settings.sproc[data.mode][data.name] = data.process;
if (!isDevice) {
api.conf.show();
}
}
if (isDevice) {
if (data.process && data.process.processName && data.code.mode) {
data.mode = data.code.mode;
data.name = data.process.processName;
isProcess = true;
}
if (settings.devices[data.device]) {
uc.confirm(`Replace device ${data.device}?`).then(yes => {
if (yes) devset()
});
} else {
devset();
}
}
if (isProcess) {
console.log({ data });
if (data.name === 'default') data.name = data.device || data.name;
if (settings.sproc[data.mode][data.name]) {
uc.confirm(`Replace process ${data.name}?`).then(yes => {
if (yes) procset();
});
} else {
procset();
}
}
if (isSettings) {
api.space.clear();
settings = normalize(data.settings);
local.setItem('ws-settings', JSON.stringify(settings));
if (api.const.LOCAL) console.log('settings', Object.clone(settings));
if (isWork) {
api.platform.clear();
// really old workspaces encoded the types as strings
for (let work of data.work) {
if (work.type === "widget") {
work.type = 100;
}
}
codec.decode(data.work).forEach(widget => {
api.platform.add(widget, 0, true, true);
});
if (data.view) {
space.view.load(data.view);
}
}
restoreSettings();
api.space.restore(() => { ui.sync() }, true);
}
if (isTools && Array.isArray(data.tools)) {
const settool = settings.tools;
for (let tool of data.tools) {
if (settool.filter(t => t.id === tool.id).length === 0) {
settool.push(tool);
}
}
// settings.tools = data.tools;
api.show.tools();
}
}
if (ask) {
let opt = {};
let prompt = isDevice ?
`Import device "${data.device}"?` : isProcess ?
`Import process "${data.name}"?` : isTools ?
`Import tool definitions?`:
`Import settings made in Kiri:Moto version ${data.version} on<br>${new Date(data.time)}?`;
if (data.screen) {
opt.pre = [
'<div class="f-col a-center">',
`<img src="${data.screen}" style="width:300px"/>`,
'</div>'
];
}
uc.confirm(prompt,undefined,undefined,opt).then((yes) => {
if (yes) doit();
});
} else {
doit();
}
}
function settingsImportUrl(url, ask) {
const kmz = url.endsWith(".kmz");
fetch(url).then(r => kmz ? r.arrayBuffer() : r.text()).then(a => {
kmz ? settingsImportZip(a, ask) : settingsImport(a, ask);
}).catch(error => {
console.log({workspace_url: url, error: error.message || error});
api.show.alert('workspace load failed');
});
}
function settingsImportZip(data, ask) {
let alert = api.show.alert("Importing Workspace");
new JSZip().loadAsync(data).then(zip => {
for (let [key,value] of Object.entries(zip.files)) {
if (key === "workspace.json") {
value.async("string").then(json => {
api.hide.alert(alert);
settingsImport(JSON.parse(json), ask);
});
}
}
});
}
// import and convert prusa ini file
function settingsPrusaConvert(data) {
const { uc } = api;
const map = {};
const vsub = {
'[first_layer_bed_temperature]': '{bed_temp}',
'[first_layer_temperature]': '{temp}',
'[layer_z]': '{z}'
};
try {
data.split('\n')
.filter(l => l.charAt(0) !== '#')
.map(l => l.split('=').map(v => v.trim()))
.map(l => {
// convert gcode string into a string array
if (l[0].indexOf('_gcode') > 0) {
l[1] = l[1].replaceAll('\\n','\n').split('\n')
.map(line => {
for (let [k,v] of Object.entries(vsub)) {
line = line.replace(k,v);
}
return line;
});
}
return l;
})
.forEach(l => {
map[l[0]] = l[1];
});
} catch (e) {
return uc.alert('invalid file');
}
// device setup
let device = Object.clone(conf.defaults.fdm.d);
let dname = device.deviceName = map.printer_model;
if (dname) {
device.mode = "FDM";
device.extruders[0].extNozzle = parseFloat(map.nozzle_diameter);
device.gcodePre = map.start_gcode;
device.gcodePost = map.end_gcode;
device.gcodeLayer = map.layer_gcode || [];
device.maxHeight = parseInt(map.max_print_height || device.maxHeight);
if (map.bed_shape) {
let shape = map.bed_shape.split(',').map(l => l.split('x'));
device.bedWidth = parseInt(shape[2][0]);
device.bedDepth = parseInt(shape[2][1]);
}
}
// profile setup
let process = Object.clone(conf.defaults.fdm.p);
let pname = process.processName = map.print_settings_id;
if (pname) {
process.sliceShells = parseInt(map.perimeters);
process.sliceHeight = parseFloat(map.layer_height);
process.outputFeedrate = parseInt(map.perimeter_speed);
process.outputSeekrate = parseInt(map.travel_speed);
process.outputTemp = parseInt(map.temperature);
process.outputBedTemp = parseInt(map.bed_temperature);
process.sliceTopLayers = parseInt(map.top_solid_layers);
process.sliceBottomLayers = parseInt(map.bottom_solid_layers);
process.firstSliceHeight = parseFloat(map.first_layer_height);
process.firstLayerNozzleTemp = parseInt(map.first_layer_temperature);
process.firstLayerRate = (
(parseFloat(map.first_layer_speed) / 100) * process.outputFeedrate);
process.firstLayerBedTemp = parseInt(map.first_layer_bed_temperature);
process.outputRetractDist = parseFloat(map.retract_length);
process.outputRetractSpeed = parseFloat(map.retract_speed);
}
uc.confirm(`Import "${dname}"?`).then(yes => {
if (yes) {
// create device, associated profile, set as current and show dialog
settings.devices[dname] = device;
settings.devproc[dname] = pname;
settings.process = settings.sproc.FDM[pname] = process;
settings.filter.FDM = dname;
settings.cproc.FDM = pname;
api.show.devices();
}
});
}
// function setThreaded(bool) {
// bool = bool ? true : false;
// settings.controller.threaded = bool;
// api.event.emit("set.threaded", bool);
// }
function setEnableWASM(bool) {
bool = bool ? true : false;
settings.controller.assembly = bool;
api.event.emit("set.assembly", bool);
}
// merged api for backward compatibility
const apiSet = {
dbo() { return ls2o('ws-settings') },
dev() { return settings.device },
proc() { return settings.process },
ctrl() { return settings.controller },
mode() { return settings.mode },
prof() { return settings.sproc[settings.mode] },
get: getSettings,
put: putSettings,
load: loadSettings,
save: saveSettings,
show: showSettings,
update: updateSettings,
update_from: updateSettingsFromFields,
update_fields: updateFieldsFromSettings,
update_fields_from_range: updateFieldsFromRange,
set_enable_wasm: setEnableWASM,
// set_threaded: setThreaded,
set_threaded() { console.trace('set_threaded deprecated' )},
restore: restoreSettings,
export: settingsExport,
import: settingsImport,
import_zip: settingsImportZip,
import_url: settingsImportUrl,
import_prusa: settingsPrusaConvert,
sync: {
async get() {},
async put() {},
status: false
}
};
// for backward compatibility
apiSet.conf = apiSet;
apiSet.set = apiSet;
apiSet.settings = apiSet;
export default apiSet;
// for backward compatibility
export {
apiSet as conf,
apiSet as set,
apiSet as settings
};

385
src/kiri/app/devices.js Normal file
View file

@ -0,0 +1,385 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { $, h } from '../../moto/webui.js';
import { api } from './api.js';
import { conf } from './config/defaults.js';
import { space } from '../../moto/space.js';
import { devices as devlist } from '../../pack/kiri-devs.js';
import { settings, conf as setconf } from './config/manager.js';
export const device = {
clone: cloneDevice,
code: currentDeviceCode,
get: currentDeviceName,
set: selectDevice,
isBelt
};
export const devices = {
show: showDevices,
select: selectDevice,
refresh: updateDeviceList,
update_laser_state: updateLaserState
};
function isBelt() {
return setconf.get().device.bedBelt;
}
function currentDeviceName() {
return setconf.get().filter[api.mode.get()];
}
function currentDeviceCode() {
return setconf.get().devices[currentDeviceName()];
}
function getModeDevices() {
return Object.keys(devlist[ api.mode.get_lower() ]).sort();
}
export function showDevices() {
settings.sync.get().then(_showDevices);
}
function _showDevices() {
updateDeviceList();
api.modal.show('setup');
}
function updateDeviceList() {
renderDevices(getModeDevices());
}
function updateDeviceName(newname) {
let selected = api.device.get(),
devs = setconf.get().devices;
if (newname !== selected) {
devs[newname] = devs[selected];
delete devs[selected];
selectDevice(newname);
updateDeviceList();
}
}
function putLocalDevice(devicename, obj) {
setconf.get().devices[devicename] = obj;
setconf.save();
}
function removeLocalDevice(devicename) {
delete setconf.get().devices[devicename];
setconf.save();
settings.sync.put();
}
function isLocalDevice(devicename) {
return setconf.get().devices[devicename] ? true : false;
}
function getSelectedDevice() {
return api.device.get();
}
function selectDevice(devicename) {
if (isLocalDevice(devicename)) {
setDeviceCode(setconf.get().devices[devicename], devicename);
} else {
let code = devlist[api.mode.get_lower()][devicename];
if (code) {
setDeviceCode(code, devicename);
}
}
}
// only for local filters
function cloneDevice() {
let name = `${getSelectedDevice().replace(/\./g,' ')}`;
let code = api.clone(setconf.get().device);
code.mode = api.mode.get();
if (name.toLowerCase().indexOf('my ') >= 0) {
name = `${name} copy`;
} else {
name = `My ${name}`;
}
putLocalDevice(name, code);
setDeviceCode(code, name);
settings.sync.put();
}
function updateLaserState() {
const dev = setconf.get().device;
$('laser-on').style.display = dev.useLaser ? 'flex' : 'none';
$('laser-off').style.display = dev.useLaser ? 'flex' : 'none';
}
function setDeviceCode(code, devicename) {
api.event.emit('device.select', devicename);
try {
if (typeof(code) === 'string') code = js2o(code) || {};
if (code.code) code = code.code;
let mode = api.mode.get(),
lmode = mode.toLowerCase(),
current = setconf.get(),
local = isLocalDevice(devicename),
dev = current.device = conf.device_from_code(code,mode),
dproc = current.devproc[devicename], // last process name for this device
newdev = dproc === undefined, // first time device is selected
predev = current.filter[mode], // previous device selection
chgdev = predev !== devicename; // device is changing
// fill missing device fields
conf.fill_cull_once(dev, conf.defaults[lmode].d);
// first time device use, add any print profiles and set to default if present
if (code.profiles) {
for (let profile of code.profiles) {
let profname = profile.processName;
// if no saved profile by that name for this mode...
if (!current.sproc[mode][profname]) {
console.log('adding profile', profname, 'to', mode);
current.sproc[mode][profname] = profile;
}
// if it's a new device, seed the new profile name as last profile
if (newdev && !current.devproc[devicename]) {
console.log('setting default profile for new device', devicename, 'to', profname);
current.devproc[devicename] = dproc = profname;
}
}
}
dev.new = false;
dev.deviceName = devicename;
let { platform, ui, uc } = api;
ui.deviceBelt.checked = dev.bedBelt;
ui.deviceRound.checked = dev.bedRound;
ui.deviceOrigin.checked = dev.ctOriginCenter || dev.originCenter || dev.bedRound;
ui.fwRetract.checked = dev.fwRetract;
ui.useIndexed.checked = dev.useIndexed;
// add extruder selection buttons
if (dev.extruders) {
let ext = api.lists.extruders = [];
dev.internal = 0;
for (let i=0; i<dev.extruders.length; i++) {
ext.push({id:i, name:i});
}
}
// disable editing for non-local devices
[
// ui.deviceName,
ui.gcodePre,
ui.gcodePost,
ui.bedDepth,
ui.bedWidth,
ui.maxHeight,
ui.useLaser,
ui.useIndexed,
ui.resolutionX,
ui.resolutionY,
ui.deviceOrigin,
ui.deviceRound,
ui.deviceBelt,
ui.fwRetract,
ui.deviceZMax,
ui.gcodeTime,
ui.gcodeFan,
ui.gcodeFeature,
ui.gcodeTrack,
ui.gcodeLayer,
ui.extFilament,
ui.extNozzle,
ui.spindleMax,
ui.gcodeSpindle,
ui.gcodeDwell,
ui.gcodeChange,
ui.gcodeFExt,
ui.gcodeSpace,
ui.gcodeStrip,
ui.gcodeLaserOn,
ui.gcodeLaserOff,
ui.laserMaxPower,
ui.extPrev,
ui.extNext,
ui.extAdd,
ui.extDel,
ui.extOffsetX,
ui.extOffsetY
].forEach(function(e) {
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' : '';
if (local) {
ui.deviceAdd.innerText = "copy";
ui.deviceDelete.style.display = '';
ui.deviceRename.style.display = '';
ui.deviceExport.style.display = '';
} else {
ui.deviceAdd.innerText = "customize";
ui.deviceDelete.style.display = 'none';
ui.deviceRename.style.display = 'none';
ui.deviceExport.style.display = 'none';
}
ui.deviceAdd.disabled = dev.noclone;
setconf.update_fields();
space.platform.setBelt(isBelt());
platform.update_size();
platform.update_origin();
platform.update();
updateLaserState();
// store current device name for this mode
current.filter[mode] = devicename;
// cache device record for this mode (restored in setMode)
current.cdev[mode] = dev;
if (dproc) {
// restore last process associated with this device
setconf.load(null, dproc);
} else {
setconf.update();
}
setconf.save();
if (isBelt()) {
// space.view.setHome(dev.bedBelt ? Math.PI/2 : 0, Math.PI / 2.5);
space.view.setHome(0, Math.PI / 2.5);
} else {
space.view.setHome(0);
}
// when changing devices, update focus on widgets
if (chgdev) {
setTimeout(api.space.set_focus, 0);
}
uc.refresh(1);
api.event.emit('device.selected', dev);
} catch (e) {
console.log({error:e, device:code, devicename});
api.show.alert(`invalid or deprecated device: "${devicename}"`, 10);
api.show.alert(`please select a new device`, 10);
throw e;
showDevices();
}
api.function.clear();
api.event.settings();
}
function renderDevices(devices) {
let selected = api.device.get() || devices[0],
features = api.feature,
devs = setconf.get().devices,
dfilter = typeof(features.device_filter) === 'function' ? features.device_filter : undefined;
for (let local in devs) {
if (!(devs.hasOwnProperty(local) && devs[local])) {
continue;
}
let dev = devs[local],
fdmCode = dev.cmd,
fdmMode = (api.mode.get() === 'FDM');
if (dev.mode ? (dev.mode === api.mode.get()) : (fdmCode ? fdmMode : !fdmMode)) {
devices.push(local);
}
};
devices = devices.sort();
let { event, ui } = api;
event.emit('devices.render', devices);
ui.deviceSave.onclick = function() {
event.emit('device.save');
api.function.clear();
setconf.save();
settings.sync.put();
showDevices();
api.modal.hide();
};
ui.deviceAdd.onclick = function() {
api.function.clear();
cloneDevice();
showDevices();
};
ui.deviceDelete.onclick = function() {
api.function.clear();
removeLocalDevice(getSelectedDevice());
selectDevice(getModeDevices()[0]);
showDevices();
};
ui.deviceRename.onclick = function() {
api.uc.prompt(`Rename "${selected}`, selected).then(newname => {
if (newname) {
updateDeviceName(newname);
setconf.save();
settings.sync.put();
showDevices();
} else {
showDevices();
}
});
};
ui.deviceExport.onclick = function(event) {
const record = {
version: api.version,
device: selected,
process: api.process.code(),
profiles: event.altKey ? settings.prof() : undefined,
code: devs[selected],
time: Date.now()
};
let exp = api.util.b64enc(record);
api.device.export(exp, selected, { event, record });
};
let dedup = {};
let list_cdev = [];
let list_mdev = [];
devices.forEach(function(device, index) {
// prevent device from appearing twice
// such as local name = standard device name
if (dedup[device]) {
return;
}
dedup[device] = device;
let loc = isLocalDevice(device);
if (dfilter && dfilter(device) === false) {
return;
}
if (loc) {
list_mdev.push(h.option(device));
} else {
list_cdev.push(h.option(device));
}
});
let dev_list = $('dev-list');
h.bind(dev_list, [
h.option({ _: '-- My Devices --', disabled: true }),
...list_mdev,
h.option({ _: '-- Stock Devices --', disabled: true }),
...list_cdev
]);
let dev_opts = [...dev_list.options].map(o => o.innerText);
dev_list.selectedIndex = dev_opts.indexOf(selected);
dev_list.onchange = ev => {
const seldev = dev_list.options[dev_list.selectedIndex];
selectDevice(seldev.innerText);
api.platform.layout();
}
selectDevice(selected);
}

172
src/kiri/app/do.js Normal file
View file

@ -0,0 +1,172 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { api } from './api.js';
import { space } from '../../moto/space.js';
const { event } = api;
let stack = [];
let stpos = 0;
let moved = { x: 0, y: 0 };
let msgid;
event.on("init-done", () => {
$('undo').onclick = undo;
$('redo').onclick = redo;
});
let undo = api.doit.undo = function() {
if (stpos) {
action(stack[--stpos].undo);
} else {
message('nothing to undo');
}
};
let redo = api.doit.redo = function() {
if (stpos < stack.length) {
action(stack[stpos++].redo);
} else {
message('nothing to redo');
}
};
let clear = api.doit.clear = function() {
stack = [];
stpos = 0;
moved = {x:0, y:0};
updateButtons();
};
function updateButtons() {
let isArrange = api.view.get() === api.consts.VIEWS.ARRANGE;
$('doit').style.display = isArrange && stack.length ? 'flex' : 'none';
$('undo').disabled = stpos === 0;
$('redo').disabled = stpos == stack.length;
}
function message(txt) {
if (msgid) {
api.hide.alert(msgid);
}
msgid = api.show.alert(txt);
}
function action(rec) {
switch (rec.type) {
case 'move':
for (let w of rec.widgets) {
w._move(rec.dist.x, rec.dist.y, 0);
}
break;
case 'rotate':
for (let w of rec.widgets) {
w.rotate(rec.x, rec.y, rec.z);
}
break;
case 'scale':
for (let w of rec.widgets) {
w._scale(rec.x, rec.y, rec.z);
}
api.selection.update_info();
break;
}
space.update();
updateButtons();
}
function pushActions(ur) {
stack.length = stpos++;
stack.push(ur);
updateButtons();
}
event.on([
'platform.layout',
'widget.add',
'widget.delete'
], (widget) => {
clear();
});
event.on('view.set', mode => {
updateButtons();
});
// selection event (store position)
event.on('selection.rotate', (rec) => {
let type = "rotate";
let widgets = api.selection.widgets(true);
if (!widgets.length) {
return;
}
let {x, y, z} = rec;
if (!(x || y || z)) {
return;
}
pushActions({
redo: {
type,
widgets,
x, y, z
},
undo: {
type,
widgets,
x: -x, y: -y, z: -z
}
})
});
event.on('selection.scale', (rec) => {
let [x, y, z] = rec;
// ignore scale close to 1
if (Math.abs(1 - (x * y * z)) < 0.0001) {
return;
}
let widgets = api.selection.widgets(true);
if (!widgets.length) {
return;
}
let type = "scale";
pushActions({
redo: {
type,
widgets,
x, y, z
},
undo: {
type,
widgets,
x: 1/x, y: 1/y, z: 1/z
}
});
});
// selection moved (accumulator)
event.on('selection.drag', (delta) => {
moved.x += delta.x;
moved.y += delta.y;
});
// move complete (store updated position)
event.on('mouse.drag.done', () => {
let widgets = api.selection.widgets(true);
if (!widgets.length) {
return;
}
let type = "move";
pushActions({
redo: {
type,
widgets,
dist: moved
},
undo: {
type,
widgets,
dist: {x: -moved.x, y: -moved.y}
}
});
moved = {x:0, y:0};
});

518
src/kiri/app/export.js Normal file
View file

@ -0,0 +1,518 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { $ } from '../../moto/webui.js';
import { api } from './api.js';
import { client } from './client.js';
import { local } from '../../data/local.js';
import { util } from '../../geo/base.js';
import { MODES } from '../core/consts.js';
import { LASER as laser_driver } from '../mode/laser/driver.js';
import { SLA as sla_client } from '../mode/sla/init-ui.js';
import { hash } from '../../ext/md5.js';
let printSeq = parseInt(local['kiri-print-seq'] || local['print-seq'] || "0") + 1;
function localGet(key) {
return api.local.get(key);
}
function localSet(key, val) {
return api.local.set(key, val);
}
export function exportFile(options) {
let mode = api.mode.get();
let names = api.widgets.all().map(w => w.meta ? w.meta.file : undefined)
.filter(v => v)
.map(v => v.replace(/ /g,'-'))
.map(v => v.substring(0,20))
.map(v => v.split('.')[0]);
api.event.emit('export', mode);
switch (mode) {
case 'WEDM':
case 'WJET':
case 'DRAG':
case 'LASER': return callExportLaser(options, names);
case 'FDM': return callExport(options, mode, names);
case 'CAM': return callExport(options, mode, names);
case 'SLA': return callExportSLA(options, names);
}
}
function callExport(callback, mode, names) {
let alert = api.feature.work_alerts ? api.show.alert("Exporting") : null;
let gcode = [];
let section = [];
let sections = { };
client.export(api.conf.get(), (line) => {
if (typeof line !== 'string') {
if (line.section) {
sections[line.section] = section = [];
}
return;
}
gcode.push(line);
section.push(line);
}, (output, error) => {
api.hide.alert(alert);
if (error) {
api.show.alert(error, 5);
} else if (callback) {
callback(gcode.join('\r\n'), output);
} else {
exportGCodeDialog(gcode, mode === 'CAM' ? sections : undefined, output, names);
}
});
}
function callExportLaser(options, names) {
client.export(api.conf.get(), (line) => {
// engine export uses lines
// console.log({unexpected_line: line});
}, (output, error) => {
// UI export uses output
if (error) {
api.show.alert(error, 5);
} else {
exportLaserDialog(output, names);
}
});
}
function callExportSLA(options, names) {
client.export(api.conf.get(), (line) => {
api.show.progress(line.progress, "exporting");
}, (output, error) => {
api.show.progress(0);
if (error) {
api.show.alert(error, 5);
} else {
sla_client.printDownload(output, api, names);
}
});
}
function exportLaserDialog(data, names) {
localSet('kiri-print-seq', printSeq++);
const fileroot = names[0] || "laser";
const filename = `${fileroot}-${(printSeq.toString().padStart(3,"0"))}`;
const settings = api.conf.get();
const driver = laser_driver;
const { process } = settings;
if (process.ctOutStack) {
$('print-stl').classList.remove('hide');
} else {
$('print-stl').classList.add('hide');
}
function download_svg() {
api.util.download(
driver.exportSVG(settings, data),
$('print-filename-laser').value + ".svg"
);
}
function download_dxf() {
api.util.download(
driver.exportDXF(settings, data),
$('print-filename-laser').value + ".dxf"
);
}
function download_stl() {
api.util.download(
driver.exportSTL(settings, data),
$('print-filename-laser').value + ".stl"
);
}
function download_gcode() {
api.util.download(
driver.exportGCode(settings, data),
$('print-filename-laser').value + ".gcode"
);
}
api.modal.show('xlaser');
let segments = 0;
data.forEach(layer => { segments += layer.length });
$('print-filename-laser').value = filename;
$('print-lines').value = util.comma(segments);
$('print-svg').onclick = download_svg;
$('print-dxf').onclick = download_dxf;
$('print-stl').onclick = download_stl;
$('print-lg').onclick = download_gcode;
}
function bindField(field, varname) {
$(field).onblur = function() {
console.log('save', field, 'to', varname);
localSet(varname, $(field).value.trim());
};
}
function exportGCodeDialog(gcode, sections, info, names) {
localSet('kiri-print-seq', printSeq++);
let settings = api.conf.get(),
MODE = api.mode.get_id(),
name = names[0] || (MODE === MODES.CAM ? "cnc" : "print"),
pre = `${name}-${(printSeq.toString().padStart(3,"0"))}`,
filename = pre,// + (new Date().getTime().toString(36)),
fileext = settings.device.gcodeFExt || "gcode",
codeproc = settings.device.gcodeProc,
octo_host,
octo_apik,
grid_host,
grid_apik,
grid_target,
grid_targets = {},
grid_local,
grid_uuid;
// join gcode array into a string
gcode = gcode.join('\r\n') + '\r\n';
// run gcode post processor function (when supplied and valid)
if (codeproc && self[codeproc]) {
gcode = self[codeproc](gcode);
}
function getBlob() {
return new Blob(
[gcode],
{type:"application/octet-stream"});
}
function sendto_octoprint() {
if (!(octo_host && octo_apik)) return;
let form = new FormData(),
ajax = new XMLHttpRequest(),
host = octo_host.value.toLowerCase(),
apik = octo_apik.value;
if (host.indexOf("http") !== 0) {
api.show.alert("host missing protocol (http:// or https://)");
return;
}
if (api.const.SECURE && !api.util.isSecure(host)) {
api.show.alert("host must begin with 'https' on a secure site");
return;
}
localSet('octo-host', host.trim());
localSet('octo-apik', apik.trim());
filename = $('print-filename').value;
form.append("file", getBlob(), filename+"."+fileext);
ajax.onreadystatechange = function() {
if (ajax.readyState === 4) {
let status = ajax.status;
api.stats.add(`ua_${api.mode.get_lower()}_print_octo_${status}`);
if (status >= 200 && status < 300) {
api.modal.hide();
} else {
api.show.alert("octoprint error\nstatus: "+status+"\nmessage: "+ajax.responseText);
}
api.show.progress(0);
}
api.show.progress(0);
};
ajax.upload.addEventListener('progress', function(evt) {
api.show.progress(evt.loaded/evt.total, "sending");
});
ajax.open("POST", host+"/api/files/local");
if (apik) {
ajax.setRequestHeader("X-Api-Key", apik);
}
ajax.send(form);
}
function download() {
filename = $('print-filename').value;
api.util.download(gcode, filename + "." + fileext);
// saveAs(getBlob(), filename + "." + fileext);
}
function pad(v) {
v = v.toString();
return v.length < 2 ? '0' + v : v;
}
function calcWeight() {
try {
let density = $('print-density');
info.weight = (
(Math.PI * util.sqr(
info.settings.device.extruders[0].extFilament / 2
)) *
info.distance *
(parseFloat(density.value) || 1.25) /
1000
).round(2);
$('print-weight').value = info.weight.toFixed(2);
density.onkeyup = (ev) => {
if (ev.key === 'Enter') calcWeight();
};
} catch (e) { }
}
function calcTime() {
let floor = Math.floor,
time = floor(info.time),
hours = floor(time / 3600),
newtime = time - hours * 3600,
mins = floor(newtime / 60),
secs = newtime - mins * 60;
$('output-time').value = [pad(hours),pad(mins),pad(secs)].join(':');
}
api.modal.show('xany');
let set = api.conf.get();
let fdm = MODE === MODES.FDM;
let octo = set.controller.exportOcto && MODE !== MODES.CAM;
let preview = set.controller.exportPreview;
$('code-preview-head').style.display = preview ? '' : 'none';
$('code-preview').style.display = preview ? '' : 'none';
$('print-download').onclick = download;
$('print-filament').style.display = fdm ? '' : 'none';
$('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";
}
calcTime();
if (fdm) {
calcWeight();
}
// persist fields when changed
bindField('octo-host', 'octo-host');
bindField('octo-apik', 'octo-apik');
// in cam mode, show zip file option
let downloadZip = $('print-zip');
downloadZip.style.display = sections ? 'flex' : 'none';
downloadZip.onclick = function() {
let files = [];
for (let [ name, data ] of Object.entries(sections)) {
if (name.indexOf('op-') === 0) {
let head = sections.header || [];
let foot = sections.footer || [];
files.push({
name: `${name}.${fileext}`,
data: [ ...head, ...data, ...foot ].join('\r\n')
})
}
}
client.zip(files, progress => {
api.show.progress(progress.percent/100, "generating zip files");
}, output => {
api.show.progress(0);
api.util.download(output, `${$('print-filename').value}.zip`);
})
};
// in fdm mode, show 3mf file option
let nozzle0 = set.device?.extruders?.[0]?.extNozzle || 0.4;
let download3MF = $('print-3mf');
download3MF.style.display = fdm ? 'flex' : 'none';
download3MF.onclick = function() {
gen3mf(zip => api.util.download(zip, `${$('print-filename').value}.3mf`));
};
// present bambu print options when selected device is bambu
if (api.bambu) {
api.bambu.prep_export(gen3mf, gcode, info, settings);
}
// let wids = api.widgets.all();
// let bnds = settings.bounds;
// console.log({ wids, bnds });
function gen3mf(then, ptype = 'unknown', ams = [0]) {
let now = new Date();
let ymd = [
now.getFullYear(),
(now.getMonth() + 1).toString().padStart(2,0),
now.getDate().toString().padStart(2,0),
].join('-');
let files = [{
name: `[Content_Types].xml`,
data: [
'<?xml version="1.0" encoding="UTF-8"?>',
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">',
' <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>',
' <Default Extension="model" ContentType="application/vnd.ms-package.3dmanufacturing-3dmodel+xml"/>',
' <Default Extension="gcode" ContentType="application/octet-stream"/>',
'</Types>'
].join('\n')
},{
name: `_rels/.rels`,
data: [
'<?xml version="1.0" encoding="UTF-8"?>',
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">',
' <Relationship Target="/3D/3dmodel.model" Id="rel-1" Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"/>',
'</Relationships>'
].join('\n')
},{
name: `3D/3dmodel.model`,
data: [
'<?xml version="1.0" encoding="UTF-8"?>',
'<model unit="millimeter" xml:lang="en-US" xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02" xmlns:BambuStudio="http://schemas.bambulab.com/package/2021">',
' <metadata name="Application">Kiri:Moto</metadata>',
' <metadata name="Copyright"></metadata>',
` <metadata name="CreationDate">${ymd}</metadata>`,
' <metadata name="Description"></metadata>',
' <metadata name="Designer"></metadata>',
' <metadata name="DesignerCover"></metadata>',
' <metadata name="License"></metadata>',
` <metadata name="ModificationDate">${ymd}</metadata>`,
' <metadata name="Origin"></metadata>',
' <metadata name="Title"></metadata>',
' <resources>',
' </resources>',
' <build/>',
'</model>'
].join('\n')
},{
name: `Metadata/_rels/model_settings.config.rels`,
data: [
'<?xml version="1.0" encoding="UTF-8"?>',
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">',
' <Relationship Target="/Metadata/plate_1.gcode" Id="rel-1" Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/gcode"/>',
'</Relationships>'
].join('\n')
// },{
// name: `Metadata/plate_1.json`,
// data: JSON.stringify({
// "bbox_all": [ 100, 100, 200, 200 ],
// "bbox_objects": (info.labels || []).map(label => {
// return {
// area: 600,
// bbox: [ 100, 100, 200, 200 ],
// id: label,
// layer_height: 0.2,
// name: "Object"
// }
// }),
// "bed_type": "textured_plate",
// "filament_colors": ["#FFFFFF"],
// "filament_ids": ams,
// "first_extruder": ams[0],
// "is_seq_print": false,
// "nozzle_diameter": 0.6,
// "version": 2
// })
},{
name: `Metadata/model_settings.config`,
data: [
'<?xml version="1.0" encoding="UTF-8"?>',
'<config>',
' <plate>',
' <metadata key="plater_id" value="1"/>',
' <metadata key="plater_name" value=""/>',
' <metadata key="locked" value="false"/>',
' <metadata key="gcode_file" value="Metadata/plate_1.gcode"/>',
' <metadata key="thumbnail_file" value="Metadata/plate_1.png"/>',
' <metadata key="thumbnail_no_light_file" value="Metadata/plate_no_light_1.png"/>',
' <metadata key="top_file" value="Metadata/top_1.png"/>',
' <metadata key="pick_file" value="Metadata/pick_1.png"/>',
' </plate>',
'</config>'
].join('\n')
},{
name: `Metadata/slice_info.config`,
data: [
'<?xml version="1.0" encoding="UTF-8"?>',
'<config>',
' <header>',
' <header_item key="X-BBL-Client-Type" value="slicer"/>',
' <header_item key="X-BBL-Client-Version" value="01.10.01.50"/>',
' </header>',
' <plate>',
' <metadata key="index" value="1"/>',
// setting this value allows for the printer to error out if
// the gcode / 3mf was intended for a different target type.
// leaving it blank bypasses the check
// ` <metadata key="printer_model_id" value="${ptype}"/>`,
` <metadata key="nozzle_diameters" value="${nozzle0}"/>`,
' <metadata key="timelapse_type" value="0"/>',
` <metadata key="prediction" value="${Math.round(info.time)}"/>`,
` <metadata key="weight" value="${info.weight}"/>`,
' <metadata key="outside" value="false"/>',
' <metadata key="support_used" value="false"/>',
' <metadata key="label_object_enabled" value="false"/>',
// (info.labels || []).map(label =>
// ` <object identify_id="${label}" name="Object" skipped="false" />`),
// ' <filament id="1" tray_info_idx="GFL96" type="PLA" color="#FFFFFF" used_m="0.17" used_g="0.50" />',
// ' <warning msg="bed_temperature_too_high_than_filament" level="1" error_code ="1000C001" />',
' </plate>',
'</config>'
].filter(v => v).flat().join('\n')
},{
name: `Metadata/project_settings.config`,
data: JSON.stringify({},undefined,4)
},{
name: `Metadata/plate_1.gcode`,
data: gcode
},{
name: `Metadata/plate_1.gcode.md5`,
data: hash(gcode)
},{
name: `Metadata/plate_1_small.png`,
data: api.view.bambu.s128.png
},{
name: `Metadata/plate_no_light_1.png`,
data: api.view.bambu.s512.png
},{
name: `Metadata/plate_1.png`,
data: api.view.bambu.s512.png
},{
name: `Metadata/pick_1.png`,
data: api.view.bambu.s512.png
},{
name: `Metadata/top_1.png`,
data: api.view.bambu.s512.png
}];
client.zip(files, progress => {
api.show.progress(progress.percent/100, "generating 3mf");
}, output => {
api.show.progress(0);
then(output);
})
};
// octoprint setup
$('send-to-octohead').style.display = octo ? '' : 'none';
$('send-to-octoprint').style.display = octo ? '' : 'none';
if (octo) try {
$('print-octoprint').onclick = sendto_octoprint;
octo_host = $('octo-host');
octo_apik = $('octo-apik');
if (MODE === MODES.CAM) {
$('send-to-octohead').style.display = 'none';
$('send-to-octoprint').style.display = 'none';
} else {
$('send-to-octohead').style.display = '';
$('send-to-octoprint').style.display = '';
}
// hide octoprint when hard-coded in the url
if (api.const.OCTO) {
$('ophost').style.display = 'none';
$('opapik').style.display = 'none';
$('oph1nt').style.display = 'none';
}
octo_host.value = localGet('octo-host') || '';
octo_apik.value = localGet('octo-apik') || '';
} catch (e) { console.log(e) }
// preview of the generated GCODE (first 64k max)
if (preview && gcode) $('code-preview-textarea').value = gcode.substring(0,65535);
}

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

@ -0,0 +1,129 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { api } from './api.js';
import { base } from '../../geo/base.js';
import { space } from '../../moto/space.js';
const DOC = self.document;
let platformColor;
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) {
api.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) {
api.platform.add(widget);
api.dialog.hide();
});
}
function updateCatalog(files) {
let table = api.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<list.length; i++) {
let row = DOC.createElement('div'),
renm = DOC.createElement('button'),
load = DOC.createElement('button'),
size = DOC.createElement('button'),
del = DOC.createElement('button'),
file = list[i],
name = file.n,
date = new Date(file.t),
split = name.split('.'),
short = split[0],
ext = split[1] ? `.${split[1]}` : '';
renm.setAttribute('class', 'rename');
renm.setAttribute('title', 'rename file');
renm.innerHTML = '<i class="far fa-edit"></i>';
renm.onclick = () => {
api.uc.prompt(`rename file`, short).then(newname => {
if (newname && newname !== short) {
api.catalog.rename(name, `${newname}${ext}`, then => {
api.modal.show('files');
});
}
});
};
load.setAttribute('load', name);
load.setAttribute('title', `file: ${name}\nvertices: ${file.v}\ndate: ${date}`);
load.onclick = loadCatalogFile;
load.appendChild(DOC.createTextNode(short));
del.setAttribute('del', name);
del.setAttribute('title', "remove '"+name+"'");
del.onclick = () => { api.catalog.deleteFile(name) };
del.innerHTML = '<i class="far fa-trash-alt"></i>';
size.setAttribute("disabled", true);
size.setAttribute("class", "label");
size.appendChild(DOC.createTextNode(base.util.comma(file.v)));
row.setAttribute("class", "f-row a-center");
row.appendChild(renm);
row.appendChild(load);
row.appendChild(size);
row.appendChild(del);
table.appendChild(row);
}
}
export const fileOps = {
dragOverHandler,
dragLeave,
dropHandler,
loadCatalogFile,
updateCatalog
};

175
src/kiri/app/files.js Normal file
View file

@ -0,0 +1,175 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
class Files {
constructor(indexdb) {
let store = this;
this.db = indexdb;
this.files = {};
this.listeners = [];
this.deferredHandler = null;
this.concurrent = 0;
this.refresh();
}
get index() {
return this.db;
}
refresh() {
let store = this;
store.db.get('files', function(files) {
if (files) {
store.files = files;
notifyFileListeners(store);
}
});
};
wipe() {
let key, files = this.files;
for (key in files) {
if (files.hasOwnProperty(key)) this.deleteFile(key);
}
};
fileList() {
return this.files;
};
addFileListener(listener) {
if (!this.listeners.contains(listener)) {
this.listeners.push(listener);
listener(this.files);
}
};
removeFileListener(listener) {
this.listeners.remove(listener);
};
setDeferredHandler(handler) {
this.deferredHandler = handler;
};
putDeferred(name, mark) {
// triggers refresh callback
this.files[name] = {
deferred: mark
};
saveFileList(this);
};
/**
* @param {String} name
* @param {Float32Array} vertices
* @param {Function} [callback]
*/
putFile(name, vertices, callback) {
console.log({ putFile: name, vertices });
let mark = Date.now();
let store = this;
let pdb = store.db.promise();
store.concurrent++;
function ondone(error) {
if (callback) callback(error);
if (--store.concurrent === 0) {
notifyFileListeners(store);
}
}
pdb.put(`file-${name}`, vertices)
.then(() => {
store.files[name] = {
vertices: vertices.length / 3,
updated: new Date().getTime()
};
return pdb.put('files', store.files);
})
.then(ondone)
.catch(ondone);
};
rename(name, newname, callback) {
if (!this.files[name]) return callback({error: 'no such file'});
if (!newname || newname == name) return callback({error: 'invalid new name'});
let done = 0;
let error = [];
let store = this;
function complete(ok, err) {
if (err) error.push(err);
if (++done === 1) {
store.files[newname] = store.files[name];
delete store.files[name];
saveFileList(store);
store.db.remove(`file-${name}`);
callback(error.length ? {error} : {});
}
}
store.db.get(`file-${name}`, (vertices) => {
if (!vertices) return complete(false, 'no raw file');
store.db.put(`file-${newname}`, vertices, complete);
});
};
/**
* @param {String} name
* @param {Function} callback
*/
getFile(name, callback) {
let store = this,
rec = store.files[name];
if (rec && rec.deferred) {
if (store.deferredHandler) return store.deferredHandler(rec.deferred, name, callback);
return callback();
}
this.db.get('file-'+name, function(vertices) {
callback(vertices);
});
};
/**
* @param {String} name
* @param {Function} callback
*/
deleteFile(name, callback) {
let store = this;
if (store.files[name]) {
delete store.files[name];
store.db.remove('file-'+name, function(ok) {
saveFileList(store);
if (callback) callback(ok);
});
return;
}
if (callback) callback(false);
};
deleteFilter(fn, from, to) {
this.db.keys(keys => {
for (let key of keys) {
if (fn(key)) {
this.db.remove(key);
}
}
}, from, to);
}
}
function saveFileList(store) {
store.db.put('files', store.files);
notifyFileListeners(store);
}
function notifyFileListeners(store) {
for (let i=0; i<store.listeners.length; i++) {
store.listeners[i](store.files);
}
}
export const openFiles = function(indexdb) {
return new Files(indexdb);
};
export { saveFileList, notifyFileListeners };

134
src/kiri/app/frame.js Normal file
View file

@ -0,0 +1,134 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { api } from './api.js';
import { load } from '../../load/file.js';
import { newWidget } from '../core/widget.js';
import { VIEWS } from '../core/consts.js';
import { widgets } from '../core/widgets.js';
// add frame message api listener
window.addEventListener('message', msg => {
const { conf, event, feature, platform, settings, show } = api;
if (!feature.frame) return;
const { origin, source, target, data } = msg;
if (source.window === target.window) return;
const send = source.window.postMessage;
if (data.mode) {
api.mode.set(data.mode.toUpperCase());
}
if (data.view) {
api.view.set(VIEWS[data.view.toUpperCase()]);
}
if (data.function) {
const cb = data.callback ? (output) => {
send({event:`${data.function}.done`, data: output});
} : undefined;
api.function[data.function.toLowerCase()](cb);
}
if (data.event) {
event.on(data.event, (evd) => {
send({event: data.event, data: evd});
});
}
if (data.emit) {
event.emit(data.emit, data.message)
}
if (data.get) switch (data.get) {
case "mode": send({mode: settings.mode()}); break;
case "device": send({device: settings.dev()}); break;
case "process": send({process: settings.proc()}); break;
case "widgets": send({widgets: widgets.all().map(w => {
return {
id: w.id,
meta: w.meta,
track: w.track,
}
})}); break;
default: send({all: settings}); break;
}
if (data.set) switch (data.set) {
case "":
break;
}
if (data.features) {
Object.assign(feature, data.features);
api.show.controls(api.feature.controls);
}
if (data.device) {
Object.assign(settings.dev(), data.device);
conf.save();
}
if (data.process){
Object.assign(settings.proc(), data.process);
conf.save();
}
if (data.controller){
let ctrl = settings.ctrl();
Object.assign(ctrl, data.controller);
api.event.emit("set.threaded", ctrl.threaded);
conf.save();
}
if (data.parse) {
let bin = data.parse;
let widget;
switch ((data.type || 'stl').toLowerCase()) {
case 'stl':
if (!bin.buffer) bin = new Float32Array(bin).buffer;
new load.STL().parse(bin, vertices => {
platform.add(widget = newWidget().loadVertices(vertices));
send({event: "parsed", data: [ widget.id ]});
});
break;
case 'obj':
// todo
break;
case '3mf':
// todo
break;
case 'svg':
let wid = [];
for (let svg of load.SVG.parse(bin)) {
if (!(svg && svg.length)) continue;
platform.add(widget = newWidget().loadVertices(svg.toFloat32()));
wid.push(widget.id);
}
send({event: "parsed", data: wid});
break;
}
}
if (data.load) {
platform.load(data.load, (verts, widget) => {
send({event: "loaded", data: [ widget.id ]});
})
};
if (data.clear) {
platform.clear();
}
if (data.alert) {
show.alert(data.alert, data.time);
}
if (data.progress >= 0) {
show.progress(data.progress, data.message);
}
});

508
src/kiri/app/function.js Normal file
View file

@ -0,0 +1,508 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { api } from './api.js';
import { client } from './client.js';
import { codec } from '../core/codec.js';
import { space } from '../../moto/space.js';
import { COLOR, PMODES } from '../core/consts.js';
import { exportFile } from './export.js';
let complete = {};
function prepareSlices(callback, scale = 1, offset = 0) {
const { conf, event, feature, hide, mode, view, platform, show, stacks } = api;
if (view.is_arrange()) {
// in arrange mode, create a screenshot at the start slicing
// this can be used later by exports and rendered on some devices
let snap = space.screenshot();
view.snapshot = snap.substring(snap.indexOf(",") + 1);
client.snap(space.screenshot2({ width: 640 }));
let bambu = view.bambu = { };
space.screenshot3({ width: 512, out(png) { bambu.s512 = png } });
space.screenshot3({ width: 128, out(png) { bambu.s128 = png } });
}
if (mode.is_sla() && !callback) {
// in SLA mode, slice and preview are the same thing
callback = preparePreview;
}
const widgets = api.widgets.all();
const settings = conf.get();
const { device, process, controller } = settings;
const isBelt = device.bedBelt || false;
const mark = Date.now();
// force layout in belt mode when widget exceeds bed length
// which has the side-effect (intended) of increasing the bed size
if (widgets.length && isBelt) {
let doLayout = false;
for (let w of widgets) {
let bb = w.getBoundingBox();
let yspan = bb.max.y - bb.min.y;
if (yspan > device.bedDepth) {
doLayout = true;
}
}
if (doLayout) {
platform.layout();
}
}
functions.clear_progress();
platform.deselect();
hide.slider();
view.set_slice();
conf.save();
// allow client.js to generate support widgets
event.emit('slice.begin', settings.mode);
// refresh widget list because 'slice.begin' may generate support widgets
const slicing = api.widgets.all()
.filter(w => !w.track.ignore && !w.meta.disabled);
if (slicing.length === 0) {
return api.show.alert('nothing to slice');
}
const totalv = slicing.map(w => w.getVertices().count).reduce((a,v) => a + v);
const defvert = totalv / slicing.length;
const track = {};
let totalProgress;
api.widgets.setOpacity(COLOR.slicing_opacity);
let segtimes = {},
segNumber = 0,
errored = false,
startTime = Date.now(),
toSlice = slicing.slice(),
camOrLaser = mode.is_cam() || mode.is_laser(),
extruders = {},
lastMsg;
for (let widget of toSlice) {
widget.belt = null;
widget.stats.progress = 0;
widget.setColor(COLOR.slicing);
let { extruder } = widget.anno;
if (extruder >= 0) {
extruders[extruder] = extruder;
}
}
// in multi-material belt mode, the anchor needs to be extended
// to allow room for the purge tower to be built. calculate here
extruders = Object.values(extruders);
if (isBelt && extruders.length > 1 && process.outputPurgeTower) {
process.beltAnchor = Math.max(
process.firstLayerBeltLead,
Math.sqrt(process.outputPurgeTower) * extruders.length * (1/Math.sqrt(2)));
} else if (isBelt) {
process.beltAnchor = process.firstLayerBeltLead;
}
stacks.clear(); // clear rendered stacks
client.clear(); // clear worker cache
client.sync(); // send fresh widget data to worker
if (isBelt) {
// belt op required to rotate meshes for slicing
client.rotate(settings);
}
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);
}
}
function sliceWidget(widget) {
function onupdate(update, msg, alert) {
if (alert) {
api.show.alert(alert);
}
if (msg && msg !== lastMsg) {
let mark = Date.now();
if (lastMsg) {
let key = slicing.length > 1 ?
`${widget.id}_${segNumber++}_${lastMsg}` :
`${segNumber++}_${lastMsg}`
segtimes[key] = mark - startTime;
}
lastMsg = msg;
startTime = mark;
}
// on update
if (update >= 0) {
track[widget.id] = (update || 0) * factor;
totalProgress = 0;
for (let w of slicing) {
totalProgress += (track[w.id] || 0);
}
show.progress(offset + (totalProgress / slicing.length) * scale, msg);
}
}
function ondone(sliced, error) {
let mark = Date.now();
// update UI info
if (sliced) {
// update segment time
if (lastMsg) {
let key = slicing.length > 1 ?
`${widget.id}_${segNumber++}_${lastMsg}` :
`${segNumber++}_${lastMsg}`
segtimes[`${key}`] = mark - startTime;
}
event.emit('slice', settings.mode);
}
// handle slicing errors
if (error && !errored) {
errored = true;
view.set_arrange();
show.alert(error, 5);
show.progress(0);
client.restart();
event.emit('slice.error', error);
setTimeout(view.set_arrange, 1);
}
if (errored) {
// terminate slicing
sliceDone();
} else {
// start next widget slice
sliceNext();
}
}
// weight each widget progress % by their # vertices
let factor = (widget.getVertices().count / defvert);
widget.settings = settings;
widget.clearSlices();
onupdate(0.0001, "slicing");
// store slicing visuals
widget.stack = api.stacks.create(widget.id, widget.mesh);
// compensate for zcut (widget moved through floor)
widget.stack.obj.view.position.z = widget.track.zcut || 0;
// in case result of slice is nothing, do not preserve previous
widget.slices = []
// executed from kiri.js
client.slice(settings, widget, (reply) => {
if (reply.alert) {
onupdate(null, null, reply.alert);
}
if (reply.update) {
onupdate(reply.update, reply.updateStatus);
}
if (reply.send_start) {
widget.xfer = {start: reply.send_start};
}
if (reply.stats) {
widget.stats = reply.stats;
}
if (reply.send_end) {
widget.stats.load_time = widget.xfer.start - reply.send_end;
}
if (reply.slice) {
widget.slices.push(codec.decode(reply.slice, {mesh:widget.mesh}));
}
if (reply.done) {
ondone(true);
}
if (reply.error) {
ondone(false, reply.error);
}
});
// discard point cache
widget.points = undefined;
}
function sliceDone() {
let alert = null;
if (scale === 1 && feature.work_alerts && slicing.length) {
alert = show.alert("Rendering");
};
for (let widget of slicing) {
// on done
let key = slicing.length > 1 ?
`${widget.id}_${segNumber++}_draw` :
`${segNumber++}_draw`
segtimes[`${key}`] = widget.render(widget.stack);
// rotate stack for belt beds
if (widget.belt) {
widget.stack.obj.rotate(widget.belt);
}
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);
api.hide.alert(alert);
}
}
view.update_slider_max(true);
show.layer(-1, 0);
if (scale === 1) {
view.update_stack_labels();
}
// if (!isBelt && controller.lineType === 'line' && !process.xray && !controller.devel) {
// $('render-ghost').onclick();
// }
if (scale === 1) {
show.progress(0);
}
// mark slicing complete for prep/preview
complete.slice = true;
event.emit('slice.end', settings.mode);
// print stats
segtimes.total = Date.now() - mark;
console.log(segtimes);
if (callback && typeof callback === 'function') {
callback();
}
if (isBelt) {
// required to render supports properly (in belt mode)
// because this updates widget top z which is affected by rotation
api.platform.update_top_z();
}
// refresh / repaint workspace
space.refresh();
}
// kick off slicing chain
sliceNext();
}
function preparePreview(callback, scale = 1, offset = 0) {
const { conf, event, feature, hide, mode, view, platform, show, stacks } = api;
const widgets = api.widgets.all();
const settings = conf.get();
const { device, process, controller } = settings;
if (complete.preview === feature.pmode) {
if (device.extruders && device.extruders.length > 1) {
if (++feature.pmode > 2) {
feature.pmode = 1;
}
} else {
if (callback) {
callback();
}
return;
}
}
if (!complete.slice) {
settings.render = false;
prepareSlices(() => { preparePreview(callback, 0.25, 0.75) }, 0.75);
return;
}
hide.slider(true);
const isCam = mode.is_cam(), pMode = settings.mode;
const isDark = api.space.is_dark();
const isBelt = device.bedBelt || false;
view.set_preview();
conf.save();
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);
} else if (offset === 0) {
api.widgets.setOpacity(COLOR.preview_opacity);
}
let mark = Date.now(),
segNumber = 0,
segtimes = {},
startTime,
lastMsg,
output = [];
// pass preview mode to worker
settings.pmode = feature.pmode;
settings.render = true;
client.prepare(settings, (progress, message, layer) => {
if (layer) {
output.push(codec.decode(layer));
}
if (message && message !== lastMsg) {
const mark = Date.now();
if (lastMsg) {
segtimes[`${segNumber++}_${lastMsg}`] = mark - startTime;
}
lastMsg = message;
startTime = mark;
}
show.progress(offset + progress * scale, message);
}, (reply, maxSpeed, minSpeed) => {
// handle worker errors
if (reply && reply.error) {
show.alert(reply.error, 5);
view.set_arrange();
show.progress(0);
space.update();
event.emit('preview.error', reply.error);
return;
}
if (lastMsg) {
segtimes[`${segNumber++}_${lastMsg}`] = Date.now() - startTime;
}
show.progress(0);
if (!isCam) {
api.widgets.setOpacity(0);
}
if (output.length) {
let alert = feature.work_alerts ? show.alert("Rendering") : null;
startTime = Date.now();
stacks.clear();
const stack = stacks.create('print', space.world)
const view = stack.obj.view;
output.forEach(layer => {
stack.add(layer);
});
event.emit('preview.view', view);
// rotate stack for belt beds
if (isBelt && widgets[0].belt) {
let ri = widgets[0].belt;
ri.dz = 0;
ri.dy = settings.device.bedDepth / 2;
stack.obj.rotate(widgets[0].belt);
}
api.hide.alert(alert);
segtimes[`${segNumber}_draw`] = Date.now() - startTime;
}
// print stats
segtimes.total = Date.now() - mark;
console.log(segtimes);
event.emit('print', pMode);
event.emit('preview.end', pMode);
space.update();
view.update_slider_max(true);
show.layer(-1, 0);
if (feature.pmode === PMODES.SPEED) {
view.update_speeds(maxSpeed, minSpeed);
} else {
view.update_speeds();
}
view.update_stack_labels();
let { controller, process } = settings;
// if (!isBelt && controller.lineType === 'line' && !process.xray && !controller.devel) {
// $('render-ghost').onclick();
// }
// mark preview complete for export
complete.preview = feature.pmode;
if (typeof(callback) === 'function') {
callback();
}
});
}
function prepareAnimation() {
if (!window.SharedArrayBuffer) {
api.alerts.show("The security context of this");
api.alerts.show("window prevents animations.");
api.alerts.show("Try a Chromium-based browser");
return;
}
api.event.emit("function.animate", {mode: api.conf.get().mode});
}
function prepareExport() {
const settings = api.conf.get();
const argsave = arguments;
if (!complete.preview) {
preparePreview(() => { prepareExport(...argsave) });
return;
}
api.event.emit("function.export", {mode: settings.mode});
complete.export = true;
exportFile(...argsave);
}
function cancelWorker() {
if (client.isBusy()) {
client.restart();
}
}
function parseCode(code, type) {
const { conf, event, show, stacks, widgets, view } = api;
const settings = conf.get();
event.emit("code.load", {code, type});
view.set_preview();
widgets.setOpacity(0);
client.parse({code, type, settings}, progress => {
show.progress(progress, "parsing");
}, (layers, maxSpeed, minSpeed) => {
show.progress(0);
stacks.clear();
const stack = stacks.create('parse', space.world);
layers.forEach(layer => stack.add(layer));
view.update_slider_max(true);
view.update_speeds(maxSpeed, minSpeed);
show.slices();
view.update_stack_labels();
space.update();
event.emit("code.loaded", {code, type});
});
}
function clear_progress() {
complete = {};
}
// extend API (api.function)
export const functions = {
slice: prepareSlices,
print: preparePreview,
prepare: preparePreview,
animate: prepareAnimation,
export: prepareExport,
cancel: cancelWorker,
parse: parseCode,
clear: client.clear,
clear_progress,
};
export {
prepareSlices as slice,
preparePreview as print,
preparePreview as prepare,
prepareAnimation as animate,
prepareExport as export,
cancelWorker as cancel,
parseCode as parse,
clear_progress,
};

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

@ -0,0 +1,17 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { selection } from './select.js';
import { Widget } from '../core/widget.js';
function groupMerge() {
Widget.Groups.merge(selection.widgets(true));
}
function groupSplit() {
Widget.Groups.split(selection.widgets(false));
}
export const group = {
merge: groupMerge,
split: groupSplit,
};

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

@ -0,0 +1,28 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { $ } from '../../moto/webui.js';
import { api } from './api.js';
import { modal } from './modal.js';
import { version } from '../../moto/license.js';
const WIN = self.window;
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}`;
modal.show('help');
api.event.emit('help.show', local);
}
export const help = {
show: showHelp,
file: showHelpFile
};

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

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

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

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

687
src/kiri/app/init-one.js Normal file
View file

@ -0,0 +1,687 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { $ } from '../../moto/webui.js';
import { api } from './api.js';
import { version } from '../../moto/license.js';
import { fileOps } from './file-ops.js';
import { init as initCAM } from '../mode/cam/init-ui.js';
import { init as initDRAG } from '../mode/drag/init-ui.js';
import { init as initFDM } from '../mode/fdm/init-ui.js';
import { init as initLaser } from '../mode/laser/init-ui.js';
import { init as initSLA } from '../mode/sla/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 './interact.js';
import { keyboard } from './keyboard.js';
import { local as sdb } from '../../data/local.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 { modal } from './modal.js';
import { preferences } from './preferences.js';
import { selectionTools } from './selection-tools.js';
import { settings as set_ctrl } from './config/manager.js';
import { settingsOps } from './settings-ops.js';
import { slider } from './slider.js';
import { space } from '../../moto/space.js';
import { VIEWS, MODES, SEED } from '../core/consts.js';
import STACKS from './stacks.js';
let { SETUP } = api.const,
{ CAM, SLA, FDM, LASER, DRAG, WJET, WEDM } = MODES,
{ catalog, platform, selection, stats } = api,
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();
});
return true;
}
}
return false;
}
// upon restore, seed presets
api.event.emit('preset', api.conf.dbo());
// api.event.on("set.threaded", bool => setThreaded(bool));
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();
}
// MAIN UI INITIALIZATION FUNCTION
function init_one() {
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;
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) {
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.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:settingsOps.updateDeviceSize}),
bedDepth: newInput('Y (depth)', {title:LANG.dv_bedw_l, convert:toFloat, size:6, units, round:2, action:settingsOps.updateDeviceSize}),
maxHeight: newInput('Z (height)', {title:LANG.dv_bedw_l, convert:toFloat, size:6, units, round:2, action:settingsOps.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:'<i class="fas fa-less-than"></i>'}),
ui.extAdd = newButton(undefined, undefined, {icon:'<i class="fas fa-plus"></i>'}),
ui.extDel = newButton(undefined, undefined, {icon:'<i class="fas fa-minus"></i>'}),
ui.extNext = newButton(undefined, undefined, {icon:'<i class="fas fa-greater-than"></i>'})
], {class:"dev-buttons ext-buttons var-row", modes:FDM}),
_____: newDiv({ class: "f-col t-body t-inset", addto: $('dev-config'), set:true, modes:CAM_LZR }),
_____: newGroup(LANG.dv_gr_out, null, {group:"dgco", inline}),
gcodeStrip: newBoolean(LANG.dv_strc_s, onBooleanClick, {title:LANG.dv_strc_l, modes:CAM}),
gcodeSpace: newBoolean(LANG.dv_tksp_s, onBooleanClick, {title:LANG.dv_tksp_l, modes:CAM_LZR}),
laserMaxPower: newInput(LANG.ou_maxp_s, {title:LANG.ou_maxp_l, modes:LASER, size:7, text:true}),
useLaser: newBoolean(LANG.dv_lazr_s, onBooleanClick, {title:LANG.dv_lazr_l, modes:CAM}),
useIndexed: newBoolean(LANG.dv_4tha_s, onBooleanClick, {title:LANG.dv_4tha_l, modes:CAM}),
gcodeFExt: newInput(LANG.dv_fext_s, {title:LANG.dv_fext_l, modes:CAM_LZR, size:7, text:true}),
gcodeEd: newGroup(LANG.dv_gr_gco, $('dg'), {group:"dgcp", inline, modes:GCODE}),
gcodeMacros: newRow([
(ui.gcodePre = newGCode(LANG.dv_head_s, {title:LANG.dv_head_l, modes:GCODE, area:gcode})).button,
(ui.gcodePost = newGCode(LANG.dv_foot_s, {title:LANG.dv_foot_l, modes:GCODE, area:gcode})).button,
(ui.gcodeLayer = newGCode(LANG.dv_layr_s, {title:LANG.dv_layr_l, modes:FDM, area:gcode})).button,
(ui.gcodeTrack = newGCode(LANG.dv_prog_s, {title:LANG.dv_prog_l, modes:FDM, area:gcode})).button,
(ui.gcodeFan = newGCode(LANG.dv_fanp_s, {title:LANG.dv_fanp_l, modes:FDM, area:gcode})).button,
(ui.gcodeFeature = newGCode(LANG.dv_feat_s, {title:LANG.dv_feat_l, modes:FDM, area:gcode})).button,
(ui.gcodeLaserOn = newGCode(LANG.dv_lzon_s, {title:LANG.dv_lzon_l, modes:LASER, area:gcode})).button,
(ui.gcodeLaserOff = newGCode(LANG.dv_lzof_s, {title:LANG.dv_lzof_l, modes:LASER, area:gcode})).button,
(ui.gcodeWaterOn = newGCode(LANG.dv_waon_s, {title:LANG.dv_waon_l, modes:WJET, area:gcode})).button,
(ui.gcodeWaterOff = newGCode(LANG.dv_waof_s, {title:LANG.dv_waof_l, modes:WJET, area:gcode})).button,
(ui.gcodeKnifeDn = newGCode(LANG.dv_dkon_s, {title:LANG.dv_dkon_l, modes:DRAG, area:gcode})).button,
(ui.gcodeKnifeUp = newGCode(LANG.dv_dkof_s, {title:LANG.dv_dkof_l, modes:DRAG, area:gcode})).button,
(ui.gcodeChange = newGCode(LANG.dv_tool_s, {title:LANG.dv_tool_l, modes:FDM_CAM, area:gcode})).button,
(ui.gcodeDwell = newGCode(LANG.dv_dwll_s, {title:LANG.dv_dwll_l, modes:CAM, area:gcode})).button,
(ui.gcodeSpindle = newGCode(LANG.dv_sspd_s, {title:LANG.dv_sspd_l, modes:CAM, area:gcode, show:() => ui.spindleMax.value > 0})).button,
(ui.gcodeResetA = newGCode(LANG.dv_resa_s, {title:LANG.dv_resa_l, modes:CAM, area:gcode, show:() => ui.useIndexed.checked})).button
], {class:"ext-buttons f-row gcode-macros"}),
/** Preferences Menu */
_____: newGroup(LANG.op_menu, $('prefs-gen1'), {inline}),
antiAlias: newBoolean(LANG.op_anta_s, preferences.booleanSave, {title:LANG.op_anta_l}),
reverseZoom: newBoolean(LANG.op_invr_s, preferences.booleanSave, {title:LANG.op_invr_l}),
ortho: newBoolean(LANG.op_orth_s, preferences.booleanSave, {title:LANG.op_orth_l}),
dark: newBoolean(LANG.op_dark_s, preferences.booleanSave, {title:LANG.op_dark_l}),
drawer: newBoolean('slide out', preferences.booleanSave, {title:'slide out settings drawer'}),
scrolls: newBoolean('scrollbars', preferences.booleanSave, {title:'show scrollbars'}),
devel: newBoolean(LANG.op_devl_s, preferences.booleanSave, {title:LANG.op_devl_l}),
_____: newGroup(LANG.op_disp, $('prefs-gen2'), {inline}),
showOrigin: newBoolean(LANG.op_shor_s, preferences.booleanSave, {title:LANG.op_shor_l}),
showRulers: newBoolean(LANG.op_shru_s, preferences.booleanSave, {title:LANG.op_shru_l}),
showSpeeds: newBoolean(LANG.op_sped_s, preferences.speedSave, {title:LANG.op_sped_l}),
shiny: newBoolean(LANG.op_shny_s, preferences.booleanSave, {title:LANG.op_shny_l, modes:FDM}),
lineType: newSelect(LANG.op_line_s, {title: LANG.op_line_l, action: preferences.lineTypeSave, modes:FDM}, "linetype"),
manifold: newBoolean(LANG.op_mani_s, preferences.booleanSave, {title: LANG.op_mani_l, modes:CAM}, "manifold"),
animesh: newSelect(LANG.op_anim_s, {title: LANG.op_anim_l, action: preferences.aniMeshSave, modes:CAM}, "animesh"),
units: newSelect(LANG.op_unit_s, {title: LANG.op_unit_l, action: preferences.unitsSave, modes:CAM}, "units"),
edgeangle: newInput(LANG.op_spoa_s, {title:LANG.op_spoa_l, convert:toFloat, size:3}),
_____: newGroup(LANG.lo_menu, $('prefs-lay'), {inline}),
autoSave: newBoolean(LANG.op_save_s, preferences.booleanSave, {title:LANG.op_save_l}),
autoLayout: newBoolean(LANG.op_auto_s, preferences.booleanSave, {title:LANG.op_auto_l}),
freeLayout: newBoolean(LANG.op_free_s, preferences.booleanSave, {title:LANG.op_free_l}),
spaceRandoX: newBoolean(LANG.op_spcx_s, preferences.booleanSave, {title:LANG.op_spcx_l, show:isBelt}),
spaceLayout: newInput(LANG.op_spcr_s, {title:LANG.op_spcr_l, convert:toFloat, size:3, units}),
_____: newGroup(LANG.xp_menu, $('prefs-xpo'), {inline: true}),
exportOcto: newBoolean(`OctoPrint`, preferences.booleanSave, {title:LANG.op_exop_l}),
exportThumb: newBoolean(`Thumbnail`, preferences.booleanSave, {modes:FDM}),
exportPreview: newBoolean(`Code Preview`, preferences.booleanSave),
_____: 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")),
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 = () => {
settingsOps.settingsSave(undefined, ui.settingsName.value);
};
// initialize modal controller
modal.init({
ui: {
modal: ui.modal,
modals: ui.modals
},
onShow: (which) => {
api.event.emit('modal.show', which);
},
onHide: () => {
api.event.emit('modal.hide');
}
});
// expose modal to API
api.modal = modal;
// slider setup
slider.init({
ui: {
layers: ui.layers,
slider: ui.slider,
sliderRange: ui.sliderRange,
sliderHold: ui.sliderHold,
sliderMid: ui.sliderMid,
sliderLo: ui.sliderLo,
sliderHi: ui.sliderHi,
sliderMin: ui.sliderMin,
sliderMax: ui.sliderMax,
sliderZero: $('slider-zero'),
},
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();
}
});
// expose slider to API
api.slider = slider;
// initialize keyboard controller
keyboard.init({
api,
platform,
selection,
slider,
space,
catalog,
sdb,
uc,
setCtrl: set_ctrl,
VIEWS,
DOC,
WIN,
rotateInputSelection: selectionTools.rotateInputSelection,
settingsLoad: settingsOps.settingsLoad
});
// expose keyboard to API
api.keyboard = keyboard;
// add mobile class to body if needed
if (space.info.mob) {
DOC.body.classList.add('mobile');
}
ui.load.onchange = function(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 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, [
'dragover', fileOps.dragOverHandler,
'dragleave', fileOps.dragLeave,
'drop', fileOps.dropHandler
]);
// Setup selection transformation bindings
selectionTools.setupSelectionBindings(ui);
$('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();
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(resolve) || checkSeed(resolve) || resolve();
});
};
export { onBooleanClick, updateTool, init_one };

212
src/kiri/app/init-two.js Normal file
View file

@ -0,0 +1,212 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { $ } from '../../moto/webui.js';
import { api } from './api.js';
import { beta, version } from '../../moto/license.js';
import { fileOps } from './file-ops.js';
import { local as sdb } from '../../data/local.js';
import { navigation } from './navigation.js';
import { preferences } from './preferences.js';
import { settings as set_ctrl } from './config/manager.js';
import { space } from '../../moto/space.js';
import { VIEWS } from '../core/consts.js';
const DOC = self.document;
const WIN = self.window;
const proto = location.protocol;
const { SETUP, LOCAL } = api.const;
const { platform, client, catalog, stats } = api;
const LANG = api.language.current;
const ui = api.ui;
const STARTMODE = SETUP.sm && SETUP.sm.length === 1 ? SETUP.sm[0] : null;
function settings() {
return api.conf.get();
}
// SECOND STAGE INIT AFTER UI RESTORED
export async 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(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();
preferences.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 (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);
};
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();
};
// Setup navigation button bindings
navigation.setupNavigation(ui, WIN, LANG);
// 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';
}

192
src/kiri/app/interact.js Normal file
View file

@ -0,0 +1,192 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
/**
* Interaction Controller (Singleton)
* Manages mouse hover, click, and drag interactions with 3D objects
*/
class InteractionControl {
#api;
#space;
#platform;
#selection;
#ui;
#settings;
#VIEWS;
#initialized = false;
// Callbacks to replace event emissions
#onHover;
#onHoverDown;
#onHoverUp;
#onDragDone;
#onSelectionDrag;
/**
* Initialize the interaction controller with dependencies
* @param {Object} config
* @param {Object} config.api - API object
* @param {Object} config.space - Space object (3D scene)
* @param {Object} config.platform - Platform object
* @param {Object} config.selection - Selection object
* @param {Object} config.ui - UI element references
* @param {Function} config.settings - Settings getter function
* @param {Object} config.VIEWS - View constants
* @param {Function} config.onHover - Called when hovering over objects
* @param {Function} config.onHoverDown - Called on mouse down over object
* @param {Function} config.onHoverUp - Called on mouse up over object
* @param {Function} config.onDragDone - Called when drag is complete
* @param {Function} config.onSelectionDrag - Called during selection drag
*/
init({ api, space, platform, selection, ui, settings, VIEWS, onHover, onHoverDown, onHoverUp, onDragDone, onSelectionDrag }) {
if (this.#initialized) {
throw new Error('Interactions already initialized');
}
this.#api = api;
this.#space = space;
this.#platform = platform;
this.#selection = selection;
this.#ui = ui;
this.#settings = settings;
this.#VIEWS = VIEWS;
this.#onHover = onHover;
this.#onHoverDown = onHoverDown;
this.#onHoverUp = onHoverUp;
this.#onDragDone = onDragDone;
this.#onSelectionDrag = onSelectionDrag;
this.#bindHoverHandlers();
this.#bindMouseHandlers();
this.#initialized = true;
}
#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);
});
}
#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'});
}
#platformOnHover(int, event) {
if (!this.#api.feature.hover) return;
if (int) this.#onHover?.({point: int, event, type: 'platform'});
}
#bindMouseHandlers() {
// Mouse down handler
this.#space.mouse.downSelect((int, event) => {
// Feature hook for custom mouse down handling
if (this.#api.feature.on_mouse_down) {
if (int) {
this.#api.feature.on_mouse_down(int, event);
return;
}
}
// 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) => {
if (this.#api.feature.hover) {
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();

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

@ -0,0 +1,398 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
/**
* Keyboard Controller (Singleton)
* Manages keyboard event handling and shortcuts
*/
class KeyboardControl {
#api;
#platform;
#selection;
#slider;
#space;
#catalog;
#sdb;
#uc;
#setCtrl;
#VIEWS;
#DOC;
#WIN;
#rotateInputSelection;
#settingsLoad;
#initialized = false;
/**
* Initialize the keyboard controller with dependencies
* @param {Object} config
* @param {Object} config.api - API object
* @param {Object} config.platform - Platform object
* @param {Object} config.selection - Selection object
* @param {Object} config.slider - Slider controller
* @param {Object} config.space - Space object
* @param {Object} config.catalog - Catalog object
* @param {Object} config.sdb - Local storage
* @param {Object} config.uc - User confirmation utility
* @param {Object} config.setCtrl - Settings controller
* @param {Object} config.VIEWS - View constants
* @param {Document} config.DOC - Document object
* @param {Window} config.WIN - Window object
* @param {Function} config.rotateInputSelection - Rotate input callback
* @param {Function} config.settingsLoad - Settings load callback
*/
init({ api, platform, selection, slider, space, catalog, sdb, uc, setCtrl, VIEWS, DOC, WIN, rotateInputSelection, settingsLoad }) {
if (this.#initialized) {
throw new Error('Keyboard already initialized');
}
this.#api = api;
this.#platform = platform;
this.#selection = selection;
this.#slider = slider;
this.#space = space;
this.#catalog = catalog;
this.#sdb = sdb;
this.#uc = uc;
this.#setCtrl = setCtrl;
this.#VIEWS = VIEWS;
this.#DOC = DOC;
this.#WIN = WIN;
this.#rotateInputSelection = rotateInputSelection;
this.#settingsLoad = settingsLoad;
this.#bindEvents();
this.#initialized = true;
}
/**
* 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);
}
#bindEvents() {
this.#space.event.addHandlers(self, [
'keyup', this.#handleKeyUp.bind(this),
'keydown', this.#handleKeyDown.bind(this),
'keypress', this.#handleKeyPress.bind(this)
]);
}
#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;
}
#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;
}
}
#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 Ctrl key combinations
if (evt.ctrlKey) {
switch (evt.key) {
case 'g': return this.#api.group.merge();
case 'u': return this.#api.group.split();
}
}
switch (evt.charCode) {
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('c'): // local devices
this.#api.show.local();
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;
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();

78
src/kiri/app/lang.js Normal file
View file

@ -0,0 +1,78 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
const LANG = self.lang = { current: {} };
const KDFL = 'en-us';
let lset = navigator.language.toLocaleLowerCase();
LANG.map = function(key) {
if (!key) {
return KDFL;
}
let tok = key.split('-');
switch (tok[0]) {
case 'da': return 'da-dk';
case 'de': return 'de-de';
case 'en': return 'en-us';
case 'es': return 'es-es';
case 'fr': return 'fr-fr';
case 'pl': return 'pl-pl';
case 'pt': return 'pt-pt';
case 'zh': return 'zh';
}
return KDFL;
};
LANG.get = function() {
return lset;
};
LANG.set = function() {
let map, key, keys = [...arguments];
// provide default if none given
if (keys.length === 0) {
keys = [lset, lset.split('-')[0], 'en'];
}
for (let i=0; i<keys.length; i++)
{
key = keys[i]
if (!(map = LANG[key])) {
continue;
}
lset = key;
let missing = [];
let invalid = [];
// default to EN values when missing
Object.keys(LANG.en).forEach(key => {
if (!map[key]) {
map[key] = LANG.en[key];
missing.push(key);
}
});
// update current map from chosen map
Object.keys(map).forEach(key => {
if (LANG.en[key]) {
let val = map[key];
// turn arrays into newline separated strings
if (Array.isArray(val)) {
val = val.join('\n');
}
LANG.current[key] = val;
} else {
invalid.push(key);
}
});
if (missing.length) {
console.log(`language map "${key}" missing keys [${missing.length}]: ${missing.join(', ')}`);
}
if (invalid.length) {
console.log(`language map "${key}" invalid keys [${invalid.length}]: ${invalid.join(', ')}`);
}
return key;
}
return undefined;
}
export default { LANG };
export { LANG };

268
src/kiri/app/layers.js Normal file
View file

@ -0,0 +1,268 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { newPolygon, Polygon } from '../../geo/polygon.js';
import { polygons as POLY } from '../../geo/polygons.js';
export class Layers {
constructor() {
this.init();
}
init() {
this.layers = {};
this.profiles = {};
this.stats = {
contour: 0,
flat_line: 0,
flat_poly: 0,
line_poly: 0,
line: 0
};
}
getLayer(layer) {
return this.layers[layer];
}
// in radians
setRotation(x = 0, y = 0, z = 0) {
this.rotation = { x, y, z };
return this;
}
setPosition(x = 0, y = 0, z = 0) {
this.position = { x, y, z };
return this;
}
setLayer(layer, colors, off) {
let layers = this.layers;
if (typeof(colors) === 'number') {
colors = {
line: colors,
face: colors,
opacity: 1
};
}
this.current = layers[layer] = layers[layer] || {
rotation: this.rotation,
position: this.position,
off: off === true,
lines: [], // basic line segments
polys: [], // colors are an attribute on polygons
faces: [], // triangles for areas and flats
norms: undefined, // flats vertex normals
cface: undefined, // flats face color indices
paths: undefined, // 3d extrusion / tube paths (used to be an array, now merged - FIX)
cpath: undefined, // path colors indices
color: colors || {
fat: 0, // render polys & lines with thickness
line: 0,
face: 0,
opacity: 1
},
};
return this;
}
// add a line segment (two points)
addLine(p1, p2) {
this.current.lines.push(p1, p2);
return this;
}
// add an array of line segments
addLines(lines, options) {
if (options) {
// the open option encodes lines as open polygons
options.open = true;
const polys = [];
for (let i=0; i<lines.length-1; i += 2) {
polys.push(new Polygon()
.append(lines[i])
.append(lines[i+1])
.setOpen());
}
return this.addPolys(polys, options);
}
for (let i=0; i<lines.length-1; i += 2) {
this.addLine(lines[i], lines[i+1]);
this.stats.line++;
}
return this;
}
// an open or closed polygon
addPoly(poly, options) {
return this.addPolys([ poly ], options);
}
// a polygon rendered as a webgl line
addPolys(polys, options) {
if (!polys) {
return this;
}
if (!Array.isArray(polys)) {
throw "polys must be an array";
}
if (polys.length === 0) {
return this;
}
if (options && options.clean) {
polys = polys.map(p => p.clean(true));
}
if (options && options.flat) {
return this.addFlats(polys, options);
}
if (options && !options.thin) {
return this.addPaths(polys, options);
}
polys = flat(polys);
if (options) {
for (let p of polys) {
if (options.z !== undefined) {
p.setZ(options.z);
}
switch (typeof(options.color)) {
case 'number': p.color = options.color; break;
case 'object': p.color = options.color.line; break;
}
}
}
this.current.polys.appendAll(polys);
this.stats.line_poly += polys.length;
return this;
}
// add an enclosed 3D polygon earcut into faces
// used for FDM solids, bridges, flats debug, CAM hole generation & SLA slice visualization
addAreas(polys, options) {
const faces = this.current.faces;
polys = Array.isArray(polys) ? polys : [ polys ];
for (let poly of polys) {
for (let ep of poly.earcut()) {
for (let p of ep.points) {
faces.push(p.x, p.y, p.z);
}
}
}
if (options && options.outline) {
this.addPolys(polys.clone(true));
}
}
// add a 2D polyline path (usually FDM extrusion paths)
addFlats(polys, options) {
const opts = options || {};
const offset = opts.offset || 1;
polys = flat(polys);
if (!polys.length) {
return;
}
const cur = this.current, faces = cur.faces;
const norms = [];
for (let poly of polys) {
const faceidx = faces.length / 3;
const path = poly.toPath2D(offset * 0.95);
const { left, right, normals } = path;
for (let p of path.faces) {
faces.push(p.x, p.y, p.z);
}
norms.push(normals);
if (opts.outline) {
let p1 = newPolygon().addPoints(left).setOpenValue(poly.open);
let p2 = newPolygon().addPoints(right).setOpenValue(poly.open);
if (poly.z) {
p1.setZ(poly.z);
p2.setZ(poly.z);
}
this.addPolys([ p1, p2 ]);
}
const color = opts.color ?
(typeof(opts.color) === 'number' ? { line: opts.color, face: opts.color } : opts.color) :
cur.color;
if (!cur.cface) {
cur.cface = [ Object.assign({ start: faceidx, count: Infinity }, color) ];
} else {
// rewrite last color count if color or opacity have changed
const pc = cur.cface[cur.cface.length - 1];
if (pc.face !== color.face || pc.opacity !== color.opacity) {
pc.count = faceidx;
cur.cface.push(Object.assign({ start: faceidx, count: Infinity }, color));
}
}
}
if (cur.norms) {
cur.norms.appendAll(norms.flat());
} else {
cur.norms = norms.flat();
}
return this;
}
// add 3D polyline path (FDM extrusion paths)
addPaths(polys, options) {
const opts = options || {};
const height = opts.height || 1;
const offset = opts.offset || 1;
polys = flat(polys);
if (!polys.length) {
return;
}
for (let poly of polys) {
if (poly.length < (poly.open ? 2 : 3)) {
continue;
}
const z = opts.z || poly.getZ();
const { faces, normals } = poly.toPath3D(offset, height, 0);
const cur = this.current;
const one = cur.paths;
// happens with very short lines being omitted
if (!(faces && normals)) {
continue;
}
if (one) {
// for some reason, incremental merge is faster than all at the end
const add = one.faces.length / 3;
const feces = new Float32Array(one.faces.length + faces.length);
const indln = one.faces.length / 3;
feces.set(one.faces);
feces.set(faces, one.faces.length);
one.faces = feces;
// allow changing colors
if (opts.color) {
if (!cur.cpath) {
cur.cpath = [ Object.assign({ start: 0, count: indln - 1 }, cur.color) ];
}
// rewrite last color count if color or opacity have changed
const pc = cur.cpath[cur.cpath.length - 1];
if (pc.face !== opts.color.face || pc.opacity !== opts.color.opacity) {
pc.count = indln;
cur.cpath.push(Object.assign({ start: indln, count: Infinity }, opts.color));
}
}
if (normals) {
one.norms.appendAll(normals);
}
} else {
cur.paths = { faces, z, norms: normals };
if (opts.color) {
cur.cpath = [ Object.assign({ start: 0, count: Infinity }, opts.color) ];
}
}
this.stats.contour++;
}
return this;
}
}
function flat(polys) {
if (Array.isArray(polys)) {
return POLY.flatten(polys.clone(true), [], true);
} else {
return POLY.flatten([polys.clone(true)], [], true);
}
}

View file

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

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

@ -0,0 +1,100 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
/**
* Modal Manager (Singleton)
* Manages modal dialog display and animations
*/
class ModalControl {
#ui;
#onShow;
#onHide;
#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;
}
/**
* Check if modal is currently visible
* @returns {boolean}
*/
visible() {
return this.#ui.modal.style.display === 'flex';
}
/**
* Show a specific modal with animation
* @param {string} which - Name of the modal to show
*/
show(which) {
let mod = this.#ui.modal,
style = mod.style,
visible = this.visible(),
info = { pct: 0 };
// hide all modals before showing another
Object.keys(this.#ui.modals).forEach(name => {
this.#ui.modals[name].style.display = name === which ? 'flex' : '';
});
const ondone = () => {
this.#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 (!this.visible()) {
return;
}
let mod = this.#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 = '';
this.#onHide?.();
}).
start();
}
}
// Export singleton instance
export const modal = new ModalControl();

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

@ -0,0 +1,111 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { $ } from '../../moto/webui.js';
import { api } from './api.js';
import { consts } from '../core/consts.js';
import { modal } from './modal.js';
import { platform } from './platform.js';
import { selection } from './select.js';
import { settings } from './config/manager.js';
const { MODES, VIEWS } = consts;
const clone = Object.clone;
let MODE = MODES.FDM;
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
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();
// because device dialog, if showing, needs to be updated
if (modal.visible()) {
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()];
}
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
};

View file

@ -0,0 +1,89 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { $ } from '../../moto/webui.js';
import { api } from './api.js';
import { space } from '../../moto/space.js';
import { settingsOps } from './settings-ops.js';
const { selection } = api;
function setupNavigation(ui, WIN, LANG) {
// bind interface action elements
$('export-support-a').onclick = (ev) => { ev.stopPropagation(); api.modal.show('don8') };
$('mode-device').onclick = api.show.devices;
$('mode-profile').onclick = settingsOps.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(); settingsOps.profileExport() };
ui.acct.export.title = LANG.acct_xpo;
$('file-new').onclick = (ev) => { ev.stopPropagation(); settingsOps.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 = () => { settingsOps.objectsExport('stl') };
$('mesh-export-obj').onclick = () => { settingsOps.objectsExport('obj') };
$('mesh-merge').onclick = selection.merge;
$('mesh-split').onclick = selection.isolateBodies;
$('context-duplicate').onclick = selection.duplicate;
$('context-mirror').onclick = selection.mirror;
$('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') };
}
export const navigation = {
setupNavigation
};

141
src/kiri/app/pack.js Normal file
View file

@ -0,0 +1,141 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
export class Packer {
constructor(w, h, spacing, opt = {}) {
this.w = w;
this.h = h;
this.spacing = spacing;
this.opt = opt;
this.init();
}
init() {
const { w, h, spacing, opt } = this;
this.root = { x: 0, y: 0, w: w, h: h };
this.max = { w: 0, h: 0 };
this.packed = false;
this.spacing = typeof(spacing) === 'number' ? spacing : 1;
this.opt = opt;
this.pad = this.spacing / 2;
}
get size() {
return { w: this.w, h: this. h };
}
resize(w, h) {
this.w = w;
this.h = h;
return this;
}
rescale(wm, hm) {
this.w *= wm;
this.h *= hm;
return this;
}
pack(blocks, onretry) {
for (;;) {
let simple = this.opt.simple;
this.fit(blocks, simple);
if (!this.packed && onretry && onretry(this)) {
this.init();
continue;
}
return this;
}
}
// array of blocks/tiles with {w,h} properties
fit(blocks, simple) {
if (simple) {
return this.#fit_simple(blocks);
} else {
return this.#fit_split(blocks);
}
}
// array of blocks/tiles with {w,h} properties
#fit_simple(blocks) {
let x = 0, y = 0, mh = 0, maxx = 0, maxy = 0;
let spacing = this.spacing;
for (let block of blocks) {
block.fit = { x, y };
this.max.w = Math.max(this.max.w, x + block.w);
this.max.h = Math.max(this.max.h, y + block.h);
maxx = Math.max(maxx, x);
maxy = Math.max(maxy, y);
mh = Math.max(mh, block.h);
x += block.w;
if (x > this.root.w) {
x = 0;
y = y + mh + spacing;
mh = 0;
} else {
x += spacing;
}
if (y > this.root.h) {
return this;
}
}
if (this.opt.invx) {
for (let block of blocks) {
block.fit.x = maxx - block.fit.x;
}
}
if (this.opt.invy) {
for (let block of blocks) {
block.fit.y = maxy - block.fit.y;
}
}
this.packed = true;
return this;
}
// array of blocks/tiles with {w,h} properties
#fit_split(blocks) {
let node, w, h;
for (let block of blocks) {
w = block.w;
h = block.h;
if (node = this.#findNode(this.root, w, h)) {
block.fit = this.#splitNode(node, w, h);
} else {
return this;
}
}
this.packed = true;
return this;
}
#findNode(root, w, h) {
if (root.used) {
return this.#findNode(root.right, w, h) || this.#findNode(root.down, w, h);
} else if (w <= root.w && h <= root.h) {
return root;
} else {
return null;
}
}
#splitNode(node, w, h) {
let spacing = this.spacing;
node.used = true;
node.down = {
x: node.x,
y: node.y + h + spacing,
w: node.w,
h: node.h - h
};
node.right = {
x: node.x + w + spacing,
y: node.y,
w: node.w - w,
h: h
};
this.max.w = Math.max(this.max.w, node.x + w);
this.max.h = Math.max(this.max.h, node.y + h);
return node;
}
}

950
src/kiri/app/platform.js Normal file
View file

@ -0,0 +1,950 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { $, h } from '../../moto/webui.js';
import { api } from './api.js';
import { ajax, js2o } from '../core/utils.js';
import { base } from '../../geo/base.js';
import { load } from '../../load/file.js';
import { space } from '../../moto/space.js';
import { Widget, newWidget } from '../core/widget.js';
import { Packer } from './pack.js';
import { COLOR, MODES } from '../core/consts.js';
import { THREE } from '../../ext/three.js';
const V0 = new THREE.Vector3(0,0,0);
let setbounds = undefined;
let grouping = false;
let topZ = 0;
function current() {
return api.conf.get();
}
function get_mode() {
return api.mode.get_id();
}
function platformUpdateOrigin(update_bounds = true) {
if (update_bounds) {
platform.update_bounds();
}
const settings = current();
const { device, process, controller, stock, bounds } = settings;
const { FDM, CAM, SLA, LASER, DRAG, WJET, WEDM } = MODES;
const TWOD = [ LASER, DRAG, WJET, WEDM ];
const MODE = get_mode();
let ruler = controller.showRulers;
let stockCenter = stock.center || { x: 0, y: 0, z: 0 };
let isIndexed = process.camStockIndexed;
let isBelt = device.bedBelt;
let origin = settings.origin = { x: 0, y: 0, z: 0 };
let center = MODE === MODES.FDM ? device.originCenter || device.bedRound :
MODE === MODES.SLA ? false :
MODE === MODES.CAM ? process.camOriginCenter :
device.originCenter || process.ctOriginCenter;
if (MODE === MODES.CAM && process.camOriginTop) {
origin.z = stock.z + 0.01;
}
if (!center) {
if (MODE === MODES.CAM) {
origin.x = (-stock.x / 2) + stockCenter.x;
origin.y = (stock.y / 2) - stockCenter.y;
} else {
if (TWOD.contains(MODE) && process.ctOriginBounds) {
let b = bounds;
origin.x = b.min.x,
origin.y = -b.min.y
} else {
origin.x = -device.bedWidth / 2;
origin.y = device.bedDepth / 2;
}
}
} else if (MODE === MODES.CAM) {
origin.x = stockCenter.x;
origin.y = -stockCenter.y;
} else if (TWOD.contains(MODE) && process.ctOriginBounds ) {
let b = bounds,
mx = bounds.min.x,
my = bounds.min.y,
xd = b.max.x - mx,
yd = b.max.y - my;
origin.x += (mx + xd / 2);
origin.y -= (my + yd / 2);
} else if (isBelt) {
origin.y = device.bedDepth / 2;
}
// CNC origin offsets
if (MODE === MODES.CAM) {
origin.x += process.camOriginOffX;
origin.y -= process.camOriginOffY;
origin.z += process.camOriginOffZ;
if (isIndexed) {
origin.y = 0;
}
}
// WireEDM origin offsets
if (MODE === MODES.WEDM && process.ctOriginBounds) {
origin.x -= process.ctOriginOffX;
origin.y += process.ctOriginOffY;
}
space.platform.setRulers(ruler, ruler, 1 / api.view.unit_scale(), 'X', isBelt ? 'Z' : 'Y');
let { x, y, z } = origin;
let oz = process.camStockIndexed ? z / 2 : z;
if (controller.showOrigin && MODE !== MODES.SLA) {
space.platform.setOrigin(x, y, oz, true);
} else {
space.platform.setOrigin(x, y, oz, false);
}
}
function platformUpdateSize(updateDark = true) {
const { process, device, controller } = current();
const { showRulers, units } = controller;
space.platform.setRound(device.bedRound);
space.platform.setSize(
parseInt(device.bedWidth || 300),
parseInt(device.bedDepth || 300),
parseFloat(device.bedHeight || 5),
parseFloat(device.maxHeight || 100)
);
const ruler = showRulers,
unitMM = units === 'mm',
gridMajor = unitMM ? 25 : 25.4,
gridMinor = unitMM ? 5 : 25.4 / 10;
if (updateDark) {
if (controller.dark) {
space.platform.set({ light: 0.08 });
space.platform.setFont({rulerColor:'#888888'});
space.platform.setGrid(gridMajor, gridMinor, 0x666666, 0x333333);
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.opacity(0.2);
space.sky.set({ color: 0xffffff, ambient: { intensity: 1.1 } });
document.body.classList.remove('dark');
}
space.platform.setSize();
}
space.platform.setRulers(ruler, ruler, 1 / api.view.unit_scale(), 'X', device.bedBelt ? 'Z' : 'Y');
platform.update_origin();
}
function platformUpdateMidZ() {
topZ = 0;
api.widgets.each(widget => {
topZ = Math.max(topZ, widget.mesh.getBoundingBox().max.z);
});
space.platform.setMaxZ(topZ);
}
function platformUpdateTopZ() {
const { process, stock } = current();
const MODE = get_mode();
api.widgets.each(widget => {
if (MODE === MODES.CAM) {
const bounds = widget.getBoundingBox();
const wzmax = bounds.max.z;
const zdelta = process.camZOffset || 0;
switch (process.camZAnchor) {
case 'top':
widget.setTopZ(stock.z - zdelta, true);
break;
case 'middle':
if (widget.isIndexed) {
widget.setTopZ(zdelta, true);
} else {
widget.setTopZ(stock.z - (stock.z - wzmax) / 2 + zdelta, true);
}
break;
case 'bottom':
widget.setTopZ(wzmax + zdelta, true);
break;
}
} else {
widget.setTopZ(0);
}
});
}
function platformUpdateStock() {
const settings = current();
const { bounds, process, mode } = settings;
const { camStockX, camStockY, camStockZ, camStockOffset, camStockIndexed } = process;
if (mode === 'CAM') {
let stock = settings.stock = {
x: camStockX,
y: camStockY,
z: camStockZ
};
// drop back to offset mode if any stock dimension is 0
if (camStockOffset || (stock.x * stock.y * stock.z === 0)) {
stock.x += bounds.max.x - bounds.min.x;
stock.y += bounds.max.y - bounds.min.y;
stock.z += bounds.max.z - bounds.min.z;
}
stock.center = {
x: (bounds.max.x + bounds.min.x) / 2,
y: (bounds.max.y + bounds.min.y) / 2,
z: camStockIndexed ? 0 : stock.z / 2
};
} else {
settings.stock = {};
}
}
function platformSetBounds(bounds) {
setbounds = bounds;
return platformUpdateBounds();
}
function platformUpdateBounds() {
const bounds = setbounds || new THREE.Box3();
if (!setbounds)
api.widgets.each(widget => {
let wp = widget.track.pos;
let wb = widget.getBoundingBox().clone();
wb.min.x += wp.x;
wb.max.x += wp.x;
wb.min.y += wp.y;
wb.max.y += wp.y;
bounds.union(wb);
});
if (bounds.isEmpty()) {
bounds.set(V0, V0);
}
current().bounds = bounds;
platformUpdateStock();
platformUpdateTopZ();
platformUpdateMidZ();
platformUpdateOrigin(false);
return bounds;
}
function platformSelectedCount() {
return api.view.is_arrange() ? api.selection.count() : 0;
}
function platformUpdateSelected() {
const settings = current();
const { device } = settings;
const { extruders } = device;
const { selection, ui } = api;
if (extruders) {
for (let i = 0; i < extruders.length; i++) {
let b = $(`sel-ext-${i}`);
if (b) b.classList.remove('pop-sel');
}
selection.for_widgets(w => {
w.setColor(COLOR.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 {
selection.for_widgets(w => {
w.setColor(COLOR.selected);
}, true);
}
}
function platformSelect(widget, shift, recurse = true) {
const { event, selection, view } = api;
if (!view.is_arrange()) {
return;
}
// apply select to entire group
if (recurse && widget && widget.group.length > 1) {
for (let w of widget.group) {
platformSelect(w, true, false);
}
return;
}
if (selection.contains(widget)) {
if (shift) {
platform.deselect(widget, recurse)
} else if (selection.count() > 1) {
platform.deselect(undefined, recurse);
platform.select(widget, false, recurse);
}
} else {
// prevent selection in slice view
if (!widget.isVisible()) {
return;
}
if (!shift) {
platform.deselect(undefined, recurse);
}
selection.add(widget);
event.emit('widget.select', widget);
widget.setColor(COLOR.selected);
$(`ws-${widget.id}`)?.classList.add('selected');
selection.update_info();
}
platform.update_selected();
space.update();
}
function platformDeselect(widget, recurse = true) {
const { selection, view } = api;
if (!view.is_arrange()) {
// don't de-select and re-color widgets in,
// for example, sliced or preview modes
return;
}
// apply deselect to entire group
if (recurse && widget && widget.group.length > 1) {
for (let w of widget.group) {
platformDeselect(w, false);
}
return;
}
if (!widget) {
api.widgets.each(function(widget) {
platform.deselect(widget);
});
return;
}
if (selection.remove(widget)) {
api.event.emit('widget.deselect', widget);
}
$(`ws-${widget.id}`)?.classList.remove('selected');
widget.setColor(COLOR.deselected);
platform.update_selected();
selection.update_info();
space.update();
}
function platformLoad(url, onload) {
if (url.toLowerCase().indexOf(".stl") > 0) {
platform.load_stl(url, onload);
} else {
ajax(url, vertices => {
vertices = js2o(vertices).toFloat32();
let widget = newWidget().loadVertices(vertices);
widget.meta.url = url;
platform.add(widget);
if (onload) onload(vertices, widget);
});
}
}
function platformLoadSTL(url, onload, formdata, credentials, headers) {
new load.STL().load(url, (vertices, filename) => {
if (vertices) {
let widget = newWidget().loadVertices(vertices);
widget.meta.file = filename;
platform.add(widget);
if (onload) {
onload(vertices, widget);
}
}
}, formdata, 1 / api.view.unit_scale(), credentials, headers);
}
function platformLoadURL(url, options = {}) {
platform.group();
load.URL.load(url, options).then(objects => {
let widgets = [];
for (let object of objects) {
let widget = newWidget(undefined, options.group).loadVertices(object.mesh);
widget.meta.file = object.file;
platform.add(widget);
widgets.push(widget);
}
platform.group_done();
api.event.emit("load.url", {
url,
options,
widgets
});
}).catch(error => {
api.show.alert(error);
});
}
function platformGroup() {
grouping = true;
}
// called after all new widgets are loaded to update group positions
function platformGroupDone(skipLayout) {
grouping = false;
Widget.Groups.loadDone();
if (api.feature.drop_layout && !skipLayout) {
platform.layout();
}
}
let deferred = [];
let deferTimeout;
function platformAdd(widget, shift, nolayout, defer) {
api.widgets.add(widget);
space.world.add(widget.mesh);
widget.anno = widget.anno || {};
widget.anno.extruder = widget.anno.extruder || 0;
if (defer) {
deferred.push({widget, shift, nolayout});
clearTimeout(deferTimeout);
deferTimeout = setTimeout(platformAddDeferred, 150);
} else {
platform.select(widget, shift);
platform.update_bounds();
api.space.auto_save();
platformChanged();
api.event.emit('widget.add', widget);
if (nolayout) {
return;
}
if (!grouping) {
platformGroupDone(nolayout);
if (!current().controller.autoLayout) {
positionNewWidget(widget);
}
}
}
}
function platformAddDeferred() {
let skiplayout = false;
for (let rec of deferred) {
let { widget, shift, nolayout } = rec;
skiplayout |= nolayout;
// platform.select(widget, shift);
if (!nolayout && !current().controller.autoLayout) {
positionNewWidget(widget);
}
}
if (!grouping) {
platformGroupDone(skiplayout);
}
api.event.emit('widget.add', deferred.map(r => r.widget));
platform.update_bounds();
api.space.auto_save();
platformChanged();
deferred = [];
}
function positionNewWidget(widget) {
if (api.widgets.count() <= 1) {
return;
}
const settings = current();
const { device } = settings;
const { bedWidth, bedDepth } = device;
const wbb = widget.getBoundingBox();
const dim = {
x: wbb.max.x - wbb.min.x,
y: wbb.max.y - wbb.min.y
};
const hdim = {
x: dim.x / 2,
y: dim.y / 2
};
const bounds = base.newBounds();
const target = base.newBounds();
const DEG2RAD = Math.PI / 180;
const WIDGETS = api.widgets.all();
// look for best position for new widget that doesn't collide
outer: for (let rad = 10; rad < 200; rad += 10) {
inner: for (let d = 0; d < 360; d += 1) {
let dx = Math.cos(d * DEG2RAD) * rad;
let dy = Math.sin(d * DEG2RAD) * rad;
bounds.set(dx - hdim.x, dx + hdim.x, dy - hdim.y, dy + hdim.y);
for (let w = 0, wl = WIDGETS.length; w < wl; w++) {
let wt = WIDGETS[w];
if (wt === widget) {
continue;
}
let tpo = wt.track.pos;
let tbb = wt.getBoundingBox();
let dim = {
x: (tbb.max.x - tbb.min.x) / 2,
y: (tbb.max.y - tbb.min.y) / 2
};
target.set(tpo.x - dim.x, tpo.x + dim.x, tpo.y - dim.y, tpo.y + dim.y);
if (target.overlaps(bounds, 5)) {
continue inner;
}
}
widget._move(dx, dy, widget.track.pos.z, true);
break outer;
}
}
}
function platformDelete(widget, defer) {
if (!widget) {
return;
}
if (Array.isArray(widget)) {
const mc = widget.slice();
for (let i = 0; i < mc.length; i++) {
platform.delete(mc[i].widget || mc[i], true);
}
platformDeletePost();
api.event.emit('widget.delete', widget);
return;
}
api.client.clear(widget);
api.widgets.remove(widget);
api.selection.remove(widget);
Widget.Groups.remove(widget);
space.world.remove(widget.mesh);
if (!defer) {
platformDeletePost();
api.event.emit('widget.delete', widget);
}
}
function platformDeletePost() {
api.view.update_slider_max();
platform.update_bounds();
if (get_mode() !== MODES.FDM) {
platform.layout();
}
space.update();
platform.update_selected();
if (api.feature.drop_layout) {
platform.layout();
}
api.space.auto_save();
platformChanged();
}
// render list of current widgets
function platformChanged() {
h.bind($('ws-widgets'), api.widgets.all().map(w => {
let color;
let file = w.meta.file || 'no name';
let fsho = file.length > 25 ? file.slice(0,20) + '..' + file.slice(-2) : file;
return [
h.div({
onmouseenter() {
color = w.getColor();
w.setColor(0x0088ff, null, false);
},
onmouseleave() {
w.setColor(color, null, false);
},
},[
h.div({ class: "widpop" }, [
h.div({ class: "widopt" }, [
h.button(
{
title: "save",
onclick() { w.saveToCatalog(null,true) }
},
[ h.i({ class:"fas fa-save" }) ]
),
h.button(
{
title: "rename",
onclick() { api.widgets.rename(w) }
},
[ h.i({ class:"fas fa-pen-to-square" }) ]
),
h.button(
{
title: "replace",
onclick() { api.widgets.replace(null,w) }
},
[ h.i({ class:"fas fa-rotate" }) ]
),
h.button(
{
id: `w-en-${w.id}`,
title: "toggle disabled",
class: w.meta.disabled ? "disabled" : "",
onclick() {
let dis = w.meta.disabled = !w.meta.disabled;
let sel = api.selection.contains(w);
api.uc.setClass($(`w-en-${w.id}`), "disabled", dis);
w.setColor(sel ? COLOR.selected : COLOR.deselected, null, false);
}
},
[ h.i({ class:"fas fa-ban" }) ]
),
h.button(
{
title: "delete",
onclick() { platformDelete(w) }
},
[ h.i({ class:"fas fa-trash" }), ]
)
]),
h.button([
h.i({ class: "fa-solid fa-caret-left" })
])
]),
h.button({
_: fsho,
id: `ws-${w.id}`,
class: "grow name",
title: file,
// onmouseenter() {
// color = w.getColor();
// w.setColor(0x0088ff, null, false);
// },
// onmouseleave() {
// w.setColor(color, null, false);
// },
onclick() {
platformSelect(w, true, false);
color = w.getColor();
}
})
])
]
}));
}
function platformSelectAll() {
api.widgets.each(widget => {
platform.select(widget, true, false)
});
}
function platformLayout() {
const MODE = get_mode();
const settings = current();
const { process, device, controller } = settings;
const { ui } = api;
const auto = ui.autoLayout.checked,
isBelt = device.bedBelt,
isArrange = api.view.is_arrange(),
layout = isArrange && auto;
let gap = controller.spaceLayout;
switch (MODE) {
case MODES.SLA:
gap = gap || (process.slaSupportLayers && process.slaSupportDensity ? 2 : 1);
break;
case MODES.CAM:
case MODES.LASER:
gap = gap || process.ctOutTileSpacing || 1;
break;
case MODES.FDM:
gap = gap || ((process.sliceSupportExtra || 0) * 2) + 1;
// auto resize device to support a larger object
if (isBelt) {
fitDeviceToWidgets();
}
break;
}
api.view.set_arrange();
api.view.hide_slices();
api.space.auto_save();
if (!isArrange) {
// skip auto-layout when not in arrange mode
api.event.emit('platform.layout');
return space.update();
}
// do not layout when switching back from slice view
if (!auto || (!space && !layout)) {
api.event.emit('platform.layout');
return space.update();
}
// TODO: in CNC mode with >1 widget, force layout min spacing @ largest tool diameter
// space parts to account for anchor in belt mode
if (isBelt) {
gap += process.firstLayerBeltLead || 0;
const WIDGETS = api.widgets.all();
let bounds = platformUpdateBounds(),
movey = -(device.bedDepth / 2);
for (let widget of WIDGETS) {
let ylen = widget.track.box.h;
// only move the root widget in the group
if (widget.id === widget.group.id) {
widget.move(0, movey + ylen/2, 0, true);
movey += ylen + gap;
}
}
if (controller.spaceLayout === 0) {
let sum = 0;
let sorted = WIDGETS.sort((a, b) => a.track.pos.y - b.track.pos.y);
sorted.forEach(w => {
w.move(0, sum, 0);
sum += w.track.box.d + 1 + gap;
});
}
if (controller.spaceRandoX) {
for (let w of WIDGETS) {
w.move(
(0.5 - Math.random()) * (device.bedWidth - w.track.box.w - process.firstLayerBrim),
0, 0);
}
}
} else {
let i, m, sz = space.platform.size(),
mp = [sz.x, sz.y],
ms = [mp[0] / 2, mp[1] / 2],
c = Widget.Groups.blocks().sort(),
p = new Packer(ms[0], ms[1], gap).fit(c);
while (!p.packed) {
ms[0] *= 1.1;
ms[1] *= 1.1;
p = new Packer(ms[0], ms[1], gap).fit(c);
}
for (i = 0; i < c.length; i++) {
m = c[i];
m.fit.x += m.w / 2;
m.fit.y += m.h / 2;
m.move(p.max.w / 2 - m.fit.x, p.max.h / 2 - m.fit.y, 0, true);
}
}
platform.update_origin();
space.update();
api.event.emit('platform.layout');
}
function platformLoadWidget(group, vertices, filename) {
const widget = newWidget(undefined, group).loadVertices(vertices.toFloat32(), true);
widget.meta.file = filename;
if (filename) widget.saveToCatalog(filename);
platformAdd(widget);
return widget;
}
function platformLoadFiles(files, group) {
platform.group();
let loading = files.length;
for (let file of files) {
const name = file.name,
reader = new FileReader(),
lower = name.toLowerCase(),
isstl = lower.endsWith(".stl"),
isobj = lower.endsWith(".obj"),
is3mf = lower.endsWith(".3mf"),
issvg = lower.endsWith(".svg"),
ispng = lower.endsWith(".png"),
isjpg = lower.endsWith(".jpg"),
iskmz = lower.endsWith(".kmz"),
isini = lower.endsWith(".ini"),
isgbr = lower.endsWith(".gbr"),
israw = lower.endsWith(".raw") || lower.indexOf('.') < 0,
isset = lower.endsWith(".b64") || lower.endsWith(".km"),
isgcode = lower.endsWith(".gcode") || lower.endsWith(".nc");
reader.file = file;
reader.onloadend = function(e) {
const data = e.target.result;
function load_dec() {
if (--loading === 0) {
platform.group_done(isgcode);
}
}
if (israw) {
platformLoadWidget(group, JSON.parse(data));
load_dec();
} else if (api.feature.on_load && (isstl || isobj || is3mf)) {
api.feature.on_load(data, file);
load_dec();
} else if (api.feature.on_add_stl && isstl) {
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);
load_dec();
} else if (isobj) {
const objs = 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_dec();
};
if (objs.length > 1 && !group) {
api.uc.confirm('group objects?').then(ok => {
group = ok ? [] : group;
ondn();
});
} else {
ondn();
}
} else if (isgbr) {
let text = data.textDecode('utf-8');
if (api.conf.get().controller.devel) {
api.event.emit('cam.parse.gerber', { data: text });
} else {
api.client.gerber2mesh(text, progress => {
api.show.progress(progress, "converting");
}, vertices => {
api.show.progress(0);
let wid = platformLoadWidget(group, vertices, name);
if (api.mode.is_cam()) {
// attach raw illustration
// api.event.emit('cam.parse.gerber', { data: text, mesh: wid.mesh });
}
});
}
} else if (is3mf) {
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_dec();
api.hide.alert(msg);
}
let msg = api.show.alert('Decoding 3MF');
load.TMF.parseAsync(data).then(models => {
api.hide.alert(msg);
if (models.length > 1 && !group) {
api.uc.confirm(`group ${models.length} objects?`).then(ok => {
if (ok) {
group = [];
}
odon(models);
});
} else {
odon(models);
}
});
} else if (isgcode) {
api.function.parse(data.textDecode('utf-8'), 'gcode');
load_dec();
} else if (issvg) {
loadSVGDialog(opt => {
group = group || [];
let svg = 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_dec();
});
}
else if (iskmz) api.settings.import_zip(data, true);
else if (isset) api.settings.import(data.textDecode('utf-8'), true);
else if (ispng) api.image.dialog(data, name);
else if (isjpg) api.image.convert(data, name);
else if (isini) api.settings.import_prusa(data.textDecode('utf-8'));
else api.show.alert(`Unsupported file: ${reader.file.name}`);
};
reader.readAsArrayBuffer(reader.file);
}
}
function loadSVGDialog(doit) {
const opt = {pre: [
"<div class='f-col a-center'>",
" <h3>Import SVG</h3>",
" <p class='t-just' style='width:300px;line-height:1.5em'>",
" Extrude a 3D model from a 2D SVG.",
" Fonts must be converted to paths in an SVG editor.",
" </p>",
" <div class='f-row t-right'><table>",
" <tr><th>z height in mm</th><td><input id='svg-depth' value='5' size='3'></td></tr>",
" <tr><th title='higher values results in more points. floating point values like 0.5 are accepted'>arc segments / mm</th><td><input id='svg-arcs' value='1' size='3'></td></tr>",
" <tr><th title='minimum number of segments in an arc. helps better represent small arcs'>minimum arc segments</th><td><input id='svg-marc' value='10' size='3'></td></tr>",
" <tr><th title='interpret dimensions as pixels using dpi'>pixel dpi (optional)</th><td><input id='svg-dpi' value='0' size='3'></td></tr>",
" <tr><th>nest shapes</th><td><input id='svg-nest' value='1' type='checkbox' checked></td></tr>",
" </table></div>",
"</div>"
]};
api.uc.confirm(undefined, {convert:true, cancel:false}, undefined, opt).then((ok) => {
let depth = Math.max(0.1, parseFloat($('svg-depth').value));
let arcs = Math.max(0.01, parseFloat($('svg-arcs').value));
let marc = Math.max(1, parseInt($('svg-marc').value));
let sdpi = Math.max(0, parseInt($('svg-dpi').value));
let soup = $('svg-nest').checked;
ok && doit({ soup, resolution: arcs, segmin: marc, depth, dpi: sdpi });
});
}
function fitDeviceToWidgets() {
let maxy = 0;
api.widgets.each(widget => {
let wb = widget.mesh.getBoundingBox().clone();
maxy = Math.max(maxy, wb.max.y - wb.min.y);
});
const { device } = current();
if (maxy > device.bedDepth) {
device.bedDepthSave = device.bedDepth;
device.bedDepth = maxy + 10;
space.platform.setSize(
parseInt(device.bedWidth),
parseInt(device.bedDepth),
parseFloat(device.bedHeight),
parseFloat(device.maxHeight)
);
space.platform.update();
return true;
}
}
// extend API (api.platform)
export const platform = {
fit: fitDeviceToWidgets,
add: platformAdd,
changed: platformChanged,
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,
update: space.platform.update,
set_font: space.platform.setFont,
show_axes: space.platform.showAxes,
show_volume: space.platform.showVolume,
top_z() { return topZ },
clear() { api.space.clear(); api.space.save(true) }
};

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

@ -0,0 +1,160 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { $c } from '../../moto/webui.js';
import { api } from './api.js';
import { base } from '../../geo/base.js';
import { space } from '../../moto/space.js';
let { client, platform } = api;
let ui, statsTimer;
// Lazy initialization to avoid circular dependencies
function getUI() {
if (!ui) ui = api.ui;
return 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();
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;
}
console.log('updateStats()');
const ui = getUI();
let { div, fps, rms, rnfo } = ui.stats;
div.style.display = 'flex';
clearTimeout(statsTimer);
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({ ...memory, ...render, render_ms: nrms, frames_sec: nfps }).map(row => {
return `<div>${row[0]}</div><label>${base.util.comma(row[1])}</label>`
}).join('');
}
}, 100);
}
export const preferences = {
unitsSave,
aniMeshSave,
lineTypeSave,
detailSave,
speedSave,
setThreaded,
booleanSave,
updateDrawer,
updateStats
};

328
src/kiri/app/select.js Normal file
View file

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

View file

@ -0,0 +1,161 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { $ } from '../../moto/webui.js';
import { api } from './api.js';
import { space } from '../../moto/space.js';
import { updateTool } from '../mode/cam/tools.js';
const { selection } = api;
function settings() {
return api.conf.get();
}
function boundsSelection() {
// 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;
}
function rotateInputSelection() {
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);
});
}
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 selectionSize(e, ui) {
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, ui) {
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;
}
function setupSelectionBindings(ui) {
// on enter but not on blur
space.event.onEnterKey([
ui.scaleX, (e) => selectionScale(e, ui),
ui.scaleY, (e) => selectionScale(e, ui),
ui.scaleZ, (e) => selectionScale(e, ui),
ui.sizeX, (e) => selectionSize(e, ui),
ui.sizeY, (e) => selectionSize(e, ui),
ui.sizeZ, (e) => selectionSize(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'), selectionRotate,
$('rot_y'), selectionRotate,
$('rot_z'), selectionRotate
], true);
}
export const selectionTools = {
rotateInputSelection,
positionSelection,
setupSelectionBindings
};

View file

@ -0,0 +1,138 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { $ } from '../../moto/webui.js';
import { api } from './api.js';
import { settings as set_ctrl } from './config/manager.js';
function settings() {
return api.conf.get();
}
function deviceExport(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 objectsExport(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 workspaceNew() {
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 profileExport() {
const opt = {pre: [
"<div class='f-col a-center gap5 mlr10'>",
" <h3>Workspace Export</h3>",
" <label>This will create a backup of your</label>",
" <label>workspace, devices, and settings</label>",
" <span class='mt10'><input id='excwork' type='checkbox'>&nbsp;Exclude meshes</span>",
"</div>"
]};
let suggestion = "workspace";
let file = api.widgets.all()[0]?.meta.file || '';
if (file) {
suggestion = `${suggestion}_${file.split('.')[0]}`.replaceAll(' ','_');
};
api.uc.confirm("Filename", {ok:true, cancel: false}, suggestion, opt).then(name => {
if (!name) return;
let work = !$('excwork').checked;
let json = api.conf.export({work, clear:true});
api.client.zip([
{name:"workspace.json", data:JSON.stringify(json)}
], progress => {
api.show.progress(progress.percent/100, "compressing workspace");
}, output => {
api.show.progress(0);
api.util.download(output, `${name}.kmz`);
});
});
}
function 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 {
api.uc.prompt("Save Settings As", cp ? lp || def : def).then(saveAs);
}
}
function settingsLoad() {
api.conf.show();
}
function updateDeviceSize() {
api.conf.update();
api.platform.update_size();
api.platform.update_origin();
}
async function sync_put() {
await set_ctrl.sync.put();
}
export const settingsOps = {
deviceExport,
objectsExport,
workspaceNew,
profileExport,
settingsSave,
settingsLoad,
updateDeviceSize,
sync_put
};

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

@ -0,0 +1,305 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
/**
* Layer Slider Controller (Singleton)
* Manages layer range selection and visualization for slice/preview views
*/
class SliderControl {
#layerLo = 0;
#layerHi = 0;
#layerMax = 0;
#ui;
#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.#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;
}
/**
* 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.#ui.sliderMax.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.#ui.sliderRange.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.#ui.sliderHold.style.marginLeft = `${lowval}px`;
this.#ui.sliderMid.style.width = `${midval}px`;
this.#ui.sliderHold.style.marginRight = `${hival}px`;
}
/**
* Show layer number labels
*/
showLabels() {
const digits = this.#layerMax.toString().length;
this.#ui.sliderZero.style.width = `${digits}em`;
this.#ui.sliderMax.style.width = `${digits}em`;
this.#ui.sliderZero.innerText = this.#layerLo;
this.#ui.sliderMax.innerText = this.#layerHi;
}
/**
* Show slider UI
*/
show() {
this.#ui.layers.style.display = 'flex';
this.#ui.slider.style.display = 'flex';
}
/**
* Hide slider UI
*/
hide() {
this.#ui.layers.style.display = 'none';
this.#ui.slider.style.display = 'none';
}
#setupUI() {
if (this.#mobile) {
this.#ui.slider.classList.add('slider-mobile');
this.#ui.sliderLo.classList.add('slider-mobile');
this.#ui.sliderHi.classList.add('slider-mobile');
}
}
#bindEvents() {
// Min/Max buttons
this.#ui.sliderMin.onclick = () => {
this.#layerLo = 0;
this.#layerHi = 0;
this.showLabels();
this.#updateVisuals();
this.#notifyChange();
};
this.#ui.sliderMax.onclick = () => {
this.#layerLo = this.#layerMax;
this.#layerHi = this.#layerMax;
this.showLabels();
this.#updateVisuals();
this.#notifyChange();
};
// Mouse hover for labels
this.#ui.slider.onmouseover = () => {
this.showLabels();
};
this.#ui.slider.onmouseleave = (ev) => {
if (!ev.buttons) {
this.hideLabels();
}
};
// Drag handlers for the three slider sections
this.#bindDrag(this.#ui.sliderLo, this.#handleLoDrag.bind(this));
this.#bindDrag(this.#ui.sliderMid, this.#handleMidDrag.bind(this));
this.#bindDrag(this.#ui.sliderHi, this.#handleHiDrag.bind(this));
}
#bindDrag(el, deltaHandler) {
const slider = this.#ui.sliderRange;
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.#ui.sliderHold.style.marginLeft);
this.#drag.mdat = this.#drag.mid = this.#ui.sliderMid.clientWidth;
this.#drag.hiat = this.#pxToInt(this.#ui.sliderHold.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.#ui.sliderHold.style.marginLeft = `${lowval}px`;
this.#ui.sliderMid.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.#ui.sliderHold.style.marginLeft = `${loval}px`;
this.#ui.sliderHold.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.#ui.sliderMid.style.width = `${midval}px`;
this.#ui.sliderHold.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();

295
src/kiri/app/stack.js Normal file
View file

@ -0,0 +1,295 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
/*
* converts `layers.js` output data structures into three.js meshes for display
*/
export class Stack {
constructor(view, freeMem, shiny) {
this._view = view;
this.view = view.newGroup();
this.slices = [];
this.meshes = [];
this.freeMem = freeMem;
newMat = shiny ? createPhongMaterial : createLambertMaterial;
}
size() {
return this.slices.length;
}
hide() {
this.view.visible = false;
}
show() {
this.view.visible = true;
}
rotate(set) {
this.view.rotation.x = -set.angle * (Math.PI/180);
this.view.position.y = -set.dy;
this.view.position.z = -set.dz;
}
destroy() {
this._view.remove(this.view);
THREE.dispose(this.view);
this.view = this.slices = this.meshes = null;
}
setFreeMem(bool) {
this.freeMem = bool;
}
setVisible(newMin, newMax) {
this.show();
for (let i=0, s=this.slices, len=s.length; i<len; i++) {
s[i].visible = i >= newMin && i <= newMax;
if (s[i].visible) this.lastVis = s[i];
}
}
setLastFraction(frac = 1) {
if (this.lastVis)
for (let mesh of this.lastVis.children) {
let geo = mesh.geometry;
let pos = geo.attributes.position;
let len = frac === 1 ? Infinity : Math.round(pos.count * frac);
geo.setDrawRange(0, len);
}
}
addLayers(layers) {
// each slice gets a group so the slice can be toggled efficiently
let group = this.view.newGroup();
this.slices.push(group);
let map = this.renderLayers(layers, group);
// by default release memory after layers are rendered
if (this.freeMem) {
layers.init();
}
// return map of layers to materials (so they can be toggled on/off)
return map;
}
renderLayers(layers, group) {
const map = {}
for (const [label, layer] of Object.entries(layers.layers)) {
map[label] = this.renderLayer(layer, group);
}
return map;
}
renderLayer(layer, group) {
function addPoly(vertices, poly) {
// unroll native polys into points
if (poly.id) {
poly = {
open: poly.open,
points: poly.points.map(p => [p.x, p.y, p.z]).flat().toFloat32()
};
}
const points = poly.points, len = points.length;
for (let i=3; i<len; i += 3) {
vertices.push(new THREE.Vector3(points[i-3], points[i-2], points[i-1]));
vertices.push(new THREE.Vector3(points[i+0], points[i+1], points[i+2]));
}
if (!poly.open) {
vertices.push(new THREE.Vector3(points[0], points[1], points[2]));
vertices.push(new THREE.Vector3(points[len-3], points[len-2], points[len-1]));
}
}
const { polys, lines, faces, cface, paths, cpath } = layer;
const { color, off, norms, rotation, position } = layer;
const { fat, order, opacity } = color;
const meshes = [];
const defstate = !off;
const mats = [];
mats.state = defstate;
if (polys.length || lines.length) {
const vert = []; // vertexes
const mat = []; // materials
const grp = []; // material groups
const geo = fat ? new THREE.LineSegmentsGeometry() : new THREE.BufferGeometry();
// map all the poly and line colors for re-use
const cmap = {}
let cidx = 0;
let last = undefined;
for (let i=0, il=polys.length; i<il; i++) {
const vl = vert.length;
const poly = polys[i];
addPoly(vert, poly);
const pc = poly.color !== undefined ? { line: poly.color, opacity } : color;
const pk = pc.line;
const cc = cmap[pk] = cmap[pk] || {
idx: cidx++,
mat: createLineMaterial(pc, mat)
};
if (last !== pk) {
if (grp.length) {
// rewrite counts for last group
const prev = grp[grp.length - 1]
prev[1] = vl;
}
grp.push([vl, Infinity, cc.idx]);
last = pk;
}
}
// for now, line segments inherit the last color
for (let i=0, il=lines.length; i<il; i += 3) {
vert.push(new THREE.Vector3(lines[i], lines[i+1], lines[i+2]));
}
// ensure at least one group using the default color settings
if (grp.length === 0) {
grp.push([0, Infinity, 0]);
mat.push(createLineMaterial(color));
}
mat.forEach(m => m.visible = defstate);
for (let i=0; i<grp.length; i++) {
const g = grp[i];
geo.addGroup(g[0], g[1], g[2]);
}
if (fat) {
// LineSegmentsGeometry does not support setFromPoints()
geo.setPositions(vert.map(v => v.toArray()).flat());
} else {
geo.setFromPoints(vert);
}
// LineSegments2 does not support multiple materials
const segs = fat ?
new THREE.LineSegments2(geo, mat[0]) :
new THREE.LineSegments(geo, mat);
if (rotation) segs.rotation.set(rotation.x, rotation.y, rotation.z);
if (position) segs.position.set(position.x, position.y, position.z);
if (order !== undefined) segs.renderOrder = order;
meshes.push(segs);
group.add(segs);
mats.appendAll(mat);
}
if (faces.length) {
const mat = [];
if (cface) {
cface.forEach(c => { mat.push(newMat(c, true)) });
} else {
mat.push(newMat(color, true));
}
mat.forEach(m => m.visible = defstate);
const geo = new THREE.BufferGeometry();
if (faces.toFloat32) {
geo.setAttribute('position', new THREE.BufferAttribute(faces.toFloat32(), 3));
} else {
geo.setAttribute('position', new THREE.BufferAttribute(faces, 3));
}
if (norms) {
geo.setAttribute('normal', new THREE.BufferAttribute(norms, 3));
} else {
geo.computeVertexNormals();
}
if (cface) {
cface.forEach((c, i) => geo.addGroup(c.start, c.count, i));
} else {
geo.addGroup(0, Infinity, 0);
}
const mesh = new THREE.Mesh(geo, mat);
if (rotation) mesh.rotation.set(rotation.x, rotation.y, rotation.z);
if (position) mesh.position.set(position.x, position.y, position.z);
meshes.push(mesh);
group.add(mesh);
mats.appendAll(mat);
}
if (paths) {
const mat = [];
if (cpath) {
cpath.forEach(c => { mat.push(newMat(c)) });
} else {
mat.push(newMat(color));
}
mat.forEach(m => m.visible = defstate);
const { index, faces, norms, z } = paths;
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(faces, 3));
if (index && index.length) {
geo.setIndex(index);
}
if (norms) {
geo.setAttribute('normal', new THREE.BufferAttribute(norms, 3));
} else {
geo.computeVertexNormals();
}
if (cpath) {
cpath.forEach((c, i) => geo.addGroup(c.start, c.count, i));
} else {
geo.addGroup(0, Infinity, 0);
}
const mesh = new THREE.Mesh(geo, mat);
mesh.position.z = z;
if (rotation) mesh.rotation.set(rotation.x, rotation.y, rotation.z);
if (position) mesh.position.set(position.x, position.y, position.z);
meshes.push(mesh);
group.add(mesh);
mats.appendAll(mat);
}
this.new_meshes = meshes;
this.meshes.appendAll(meshes);
mats.forEach(mat => mat.visible = defstate);
return mats;
}
}
let shininess = 15,
specular = 0x444444,
emissive = 0x101010,
metalness = 0,
roughness = 0.3,
newMat = createPhongMaterial;
function createLineMaterial(color, array) {
const opacity = color.lopacity || color.opacity || 1;
const mat = color.fat ? new THREE.LineMaterial({
// transparent: true,
// opacity: opacity,
color: color.line,
linewidth: color.fat,
alphaToCoverage: false
}) : new THREE.LineBasicMaterial({
transparent: true,
opacity: opacity,
color: color.line
});
if (array) {
array.push(mat);
}
return mat;
}
function createStandardMaterial(color, flat) {
return new THREE.MeshMatcapMaterial({
transparent: color.opacity != 1,
opacity: color.opacity || 1,
color: color.face,
side: flat ? THREE.DoubleSide : THREE.FrontSide
});
}
function createPhongMaterial(color, flat) {
return new THREE.MeshPhongMaterial({
shininess,
specular,
transparent: color.opacity != 1,
opacity: color.opacity || 1,
color: color.face,
side: flat ? THREE.DoubleSide : THREE.FrontSide
});
}
function createLambertMaterial(color, flat) {
return new THREE.MeshLambertMaterial({
transparent: color.opacity != 1,
opacity: color.opacity || 1,
color: color.face,
side: flat ? THREE.DoubleSide : THREE.FrontSide
});
}

181
src/kiri/app/stacks.js Normal file
View file

@ -0,0 +1,181 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { $ } from '../../moto/webui.js';
import { api } from './api.js';
import { Stack } from './stack.js';
let freeMem = true,
stacks = {},
tallest = 0,
min = 0,
max = 0,
labels, API, UC, UI, DYN;
function init() {
labels = $("layers");
API = api,
UC = API.uc,
UI = API.ui;
}
function setFreeMem(bool) {
Object.values(stacks).forEach(stack => stack.obj.setFreeMem(bool));
freeMem = bool;
return this;
}
function clear() {
if (!API) {
init();
}
// remove stacks from their views
for (const [stack, data] of Object.entries(stacks)) {
data.clear();
}
min = max = tallest = 0;
stacks = {};
DYN = UI.dyn = {};
// clear labels
UC.setGroup(labels);
labels.innerHTML = '';
}
function rotate(set) {
Object.values(stacks).forEach(stack => {
stack.obj.rotate(set);
});
}
function getStack(name) {
return stacks[name];
}
function getStacks() {
return Object.entries(stacks);
}
function getLabels() {
return Object.keys(DYN);
}
function setVisible(label, bool) {
let ctrl = DYN[label];
if (ctrl) {
ctrl.toggle.checked = bool;
ctrl.group.forEach(mat => {
mat.visible = bool;
});
}
}
function create(name, view) {
if (stacks[name]) {
return stacks[name];
}
const stack = stacks[name] = {
layers: [ ],
obj: new Stack(view, freeMem, api.conf.get().controller.shiny),
add: function(layers) {
let lmap = layers.layers;
let map = stack.obj.addLayers(layers);
for (const [label, mats] of Object.entries(map)) {
if (!DYN[label]) {
let check = DYN[label] = {
group: [],
toggle: UC.newBoolean(label, (abc) => {
ctrl.group.forEach(mat => {
mat.visible = ctrl.toggle.checked;
});
if (ctrl.toggle.checked) {
api.event.emit("stack.show", label);
} else {
api.event.emit("stack.hide", label)
}
})
};
let color = lmap[label].color.check;
if (color !== undefined) {
let div = document.createElement('div');
div.setAttribute('class', 'color');
div.style.backgroundColor = `#${color.toString(16).padStart(6,0)}`;
check.toggle.parentNode.appendChild(div);
check.toggle.classList.add("color");
}
}
const ctrl = DYN[label];
ctrl.group.appendAll(mats);
ctrl.toggle.checked = mats.state;
}
tallest = Math.max(tallest, stack.obj.size());
},
remove: function() {
view.remove(stack.obj.view);
THREE.dispose(stack.obj.view);
},
clear: function() {
view.remove(stack.obj.view);
THREE.dispose(stack.obj.view);
},
button: function(label, action) {
UC.newRow([ UC.newButton(label, action) ]);
}
};
return stack;
}
function remove(name) {
const stack = stacks[name];
if (stack) {
stack.remove();
delete stacks[name];
}
}
function getRange() {
return {min, max, tallest};
}
function setRange(newMin, newMax) {
Object.values(stacks).forEach(stack => {
stack.obj.setVisible(newMin,newMax);
});
min = newMin;
max = newMax;
}
function setFraction(frac) {
Object.values(stacks).forEach(stack => {
stack.obj.setLastFraction(frac);
});
}
export default {
clear,
create,
rotate,
remove,
getStack,
getStacks,
getRange,
setRange,
getLabels,
setVisible,
setFraction,
setFreeMem
};
export {
clear,
create,
rotate,
remove,
getStack,
getStacks,
getRange,
setRange,
getLabels,
setVisible,
setFraction,
setFreeMem
};

53
src/kiri/app/stats.js Normal file
View file

@ -0,0 +1,53 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { local } from '../../data/local.js';
import { broker } from '../../moto/broker.js';
import { js2o, o2js } from '../core/utils.js';
class Stats {
constructor(db) {
this.db = db || local;
this.obj = js2o(this.db['stats'] || '{}');
let o = this.obj, k;
for (k in o) {
if (!o.hasOwnProperty(k)) continue;
if (['dn','lo','re'].indexOf(k) >= 0 || k.indexOf('-') > 0 || k.indexOf('_') > 0) {
delete o[k];
}
}
}
save(quiet) {
this.db['stats'] = o2js(this.obj);
if (!quiet) {
broker.publish('stats', this.obj);
}
return this;
}
get(k) {
return this.obj[k];
}
set(k,v,quiet) {
this.obj[k] = v;
this.save(quiet);
return this;
}
add(k,v,quiet) {
this.obj[k] = (this.obj[k] || 0) + (v || 1);
this.save(quiet);
return this;
}
del(k, quiet) {
delete this.obj[k];
this.save(quiet);
return this;
}
}
export const stats = new Stats();
export { stats as Stats };

190
src/kiri/app/tools.js Normal file
View file

@ -0,0 +1,190 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { api } from './api.js';
import { THREE } from '../../ext/three.js';
import { space } from '../../moto/space.js';
const { Vector3, Quaternion } = THREE;
const XAXIS = new THREE.Vector3(1,0,0);
const ZAXIS = new THREE.Vector3(0,0,1);
// circle
let pgeo = new THREE.CircleGeometry(8, 30);
let pmat = new THREE.MeshBasicMaterial({color: 0xddeeff, opacity: 0.6, transparent: true});
let pmesh = new THREE.Mesh(pgeo, pmat);
// circle outline
let pi = pgeo.index.array;
let pp = pgeo.attributes.position.array;
let np = [];
for (let i=0; i<pi.length; i++) {
if (pi[i] * pi[i+1]) {
np.push(pp[pi[i]*3+0]);
np.push(pp[pi[i]*3+1]);
np.push(pp[pi[i]*3+2]);
np.push(pp[pi[i+1]*3+0]);
np.push(pp[pi[i+1]*3+1]);
np.push(pp[pi[i+1]*3+2]);
}
}
let wgeo = new THREE.BufferGeometry();
wgeo.setAttribute('position', new THREE.BufferAttribute(np.toFloat32(), 3));
let wmat = new THREE.LineBasicMaterial({ color: 0x6699cc });
let wmesh = new THREE.LineSegments(wgeo, wmat);
pmesh.add(wmesh);
let alert;
let lastobj;
let lastface;
let enabled = false;
let opName = 'lay flat';
let onDone = onLayFlatSelect;
function onLayFlatSelect() {
let q = new Quaternion().setFromUnitVectors(lastface.normal, new Vector3(0,0,-1));
api.selection.rotate(q);
// endIt();
}
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();
// Target direction on XY plane (negative Y axis)
let targetDir = new Vector3(-1, 0, 0);
// Calculate angle between projected normal and target
let angle = Math.atan2(
projectedNormal.x * targetDir.y - projectedNormal.y * targetDir.x, // cross product Z component
projectedNormal.x * targetDir.x + projectedNormal.y * targetDir.y // dot product
);
// Create quaternion for Z-axis rotation only
let q = new Quaternion().setFromAxisAngle(ZAXIS, angle);
api.selection.rotate(q);
}
function onFaceUpSelect() {
api.event.emit('tool.mesh.face-normal', lastface.normal);
// endIt();
}
function startIt() {
if (enabled) {
endIt();
return;
}
if (api.feature.hover) {
console.log(`${opName} cannot pre-empt hover`);
return;
}
api.feature.hover = true;
enabled = true;
alert = api.show.alert(`[ESC] to end ${opName}`, 600000);
}
function endIt() {
if (enabled) {
api.hide.alert(alert);
api.feature.hover = false;
enabled = false;
alert = undefined;
cleanup();
}
}
function cleanup() {
if (lastobj) {
lastobj.remove(pmesh);
lastobj = undefined;
}
}
api.event.on('key.esc', endIt);
api.event.on('tool.camera.focus', (fn) => {
opName = 'camera focus';
onDone = fn;
startIt();
});
api.event.on('tool.mesh.face-up', () => {
opName = 'face select';
onDone = onFaceUpSelect;
startIt();
});
api.event.on('tool.mesh.lay-flat', () => {
opName = 'lay flat';
onDone = onLayFlatSelect;
startIt();
});
api.event.on('tool.mesh.lefty', () => {
opName = 'align y axis';
onDone = onLeftySelect;
startIt();
});
function scale() {
let cam = space.internals().camera;
let dist = cam.position.distanceTo(pmesh.position);
let scale = opName === 'camera focus' ?
Math.min(0.25, 100 / dist) : dist / 100;
pmesh.scale.set(scale,scale,scale);
}
api.event.on("space.view.zoom", scale);
api.event.on('mouse.hover', (ev) => {
if (!enabled) {
return;
}
cleanup();
let { int, ints, event, point, type } = ev;
if (type === 'widget') {
lastface = int.face;
let obj = lastobj = int.object;
let norm = int.face.normal;
let widget = obj.widget;
let track = widget.track;
let opos = track.pos;
obj.add(pmesh);
pmesh.position.x = point.x - opos.x + norm.x * 0.1;
if (track.indexed) {
let rad = track.indexRad;
let py = point.y * 1.001;
let pz = point.z * 1.001;
let v = new THREE.Vector3(point.x, py, pz).applyAxisAngle(XAXIS, rad);
pmesh.position.y = -v.z;
pmesh.position.z = v.y;
} else {
const zcomp = api.mode.get() === 'CAM' ? track.tzoff : 0;
pmesh.position.y = -point.z - opos.y + norm.y * 0.1;
pmesh.position.z = point.y + zcomp + norm.z * 0.1;
}
let q = new Quaternion().setFromUnitVectors(ZAXIS,
new Vector3(norm.x,norm.y,norm.z)
);
pmesh.setRotationFromQuaternion(q);
scale();
}
});
api.event.on('mouse.hover.up', (ev) => {
if (!enabled) {
return;
}
let { object } = ev;
if (!object) {
return;
}
onDone(ev);
endIt();
});
export { onLayFlatSelect, onFaceUpSelect, startIt, endIt, cleanup, scale, onDone };

52
src/kiri/app/util.js Normal file
View file

@ -0,0 +1,52 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { $ } from '../../moto/webui.js';
import { api } from './api.js';
const WIN = self.window;
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 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 downloadBlob(data, filename) {
let url = WIN.URL.createObjectURL(new Blob([data], {type: "octet/stream"}));
$('mod-any').innerHTML = `<a id="_dexport_" href="${url}" download="${filename}">x</a>`;
$('_dexport_').click();
}
export const util = {
isSecure,
download: downloadBlob,
setProgress,
loadFile
};

View file

@ -0,0 +1,77 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { $ } from '../../moto/webui.js';
import { api } from './api.js';
import { consts } from '../core/consts.js';
import { platform } from './platform.js';
import { selection } from './select.js';
import { settings } from './config/manager.js';
import STACKS from './stacks.js';
const { VIEWS } = consts;
const DOC = self.document;
let viewMode = VIEWS.ARRANGE;
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);
api.widgets.setOpacity(1);
api.view.edges(api.local.getBoolean('model.edges'));
break;
case VIEWS.SLICE:
$('act-slice').classList.add('selected');
api.visuals.update_speeds();
api.visuals.update_slider_max();
api.visuals.set_widget_visibility(true);
!isCAM && api.view.edges(false);
break;
case VIEWS.PREVIEW:
$('act-preview').classList.add('selected');
api.visuals.set_widget_visibility(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();
}
export const view = {
get() { return viewMode },
set() { setViewMode(...arguments) },
set_arrange() { setViewMode(VIEWS.ARRANGE) },
set_slice() { setViewMode(VIEWS.SLICE) },
set_preview() { setViewMode(VIEWS.PREVIEW) },
set_animate() { setViewMode(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 }
};

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

@ -0,0 +1,181 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { api } from './api.js';
import { consts } from '../core/consts.js';
import { settings } from './config/manager.js';
import { space as SPACE } from '../../moto/space.js';
import STACKS from './stacks.js';
const { COLOR, VIEWS } = consts;
// Note: viewMode is imported dynamically via api.view.get()
function bound(v,min,max) {
return Math.max(min,Math.min(max,v));
}
function unitScale() {
return api.mode.is_cam() && settings.ctrl().units === 'in' ? 25.4 : 1;
}
function updateSpeeds(maxSpeed, minSpeed) {
const { ui } = api;
const viewMode = api.view.get();
ui.speeds.style.display =
maxSpeed &&
settings.mode !== 'SLA' &&
settings.mode !== 'LASER' &&
viewMode === VIEWS.PREVIEW &&
ui.showSpeeds.checked ? 'block' : '';
if (maxSpeed) {
const colors = [];
for (let i = 0; i <= maxSpeed; i += maxSpeed / 20) {
colors.push(Math.round(Math.max(i, 1)));
}
api.client.colors(colors, maxSpeed, speedColors => {
const list = [];
Object.keys(speedColors).map(v => parseInt(v)).sort((a, b) => b - a).forEach(speed => {
const color = speedColors[speed];
const hex = color.toString(16).padStart(6, 0);
const r = (color >> 16) & 0xff;
const g = (color >> 8) & 0xff;
const b = (color >> 0) & 0xff;
const style = `background-color:#${hex}`;
list.push(`<label style="${style}">${speed}</label>`);
});
ui.speedbar.innerHTML = list.join('');
});
api.event.emit('preview.speeds', {
min: minSpeed,
max: maxSpeed
});
}
}
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();
}
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();
}
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.slider.setMax(max);
const { hi } = api.slider.getRange();
if (set || max < hi) {
api.slider.setRange(max, max);
api.slider.showLabels();
}
}
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) {
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();
}
function showSlider() {
api.slider.show();
}
function hideSlider() {
api.slider.hide();
api.ui.speeds.style.display = 'none';
}
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);
}
}
export const visuals = {
wireframe: setWireframe,
edges: setEdges,
unit_scale: unitScale,
update_speeds: updateSpeeds,
update_slider: updateSlider,
update_slider_max: updateSliderMax,
update_stack_labels: updateStackLabelState,
show_slices: showSlices,
hide_slices: hideSlices,
show_slider: showSlider,
hide_slider: hideSlider,
set_visible_layer: setVisibleLayer,
set_widget_visibility: setWidgetVisibility
};

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

@ -0,0 +1,177 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { THREE } from '../../ext/three.js';
import { api } from './api.js';
import { local as SDB } from '../../data/local.js';
import { openFiles } from './files.js';
import { platform } from './platform.js';
import { selection } from './select.js';
import { settings } from './config/manager.js';
import { space as SPACE } from '../../moto/space.js';
import { utils } from '../core/utils.js';
import { Widget } from '../core/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'));
let autoSaveTimer = null;
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);
}
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 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 is_dark() {
return settings.ctrl().dark;
}
export const workspace = {
reload,
auto_save,
restore: restoreWorkspace,
clear: clearWorkspace,
save: saveWorkspace,
set_focus: setFocus,
update: SPACE.update,
is_dark
};