start ui code re-organization

This commit is contained in:
Stewart Allen 2025-12-24 21:14:46 -05:00
commit a752c18855
28 changed files with 36 additions and 37 deletions

View file

@ -1,75 +0,0 @@
/** 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
};

View file

@ -1,8 +1,8 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import alerts from './alerts.js';
import alerts from '../ui/alerts.js';
import settings from './settings.js'
import STACKS from './stacks.js';
import STACKS from '../ui/stacks.js';
import { broker } from '../../moto/broker.js';
import { client as work } from './client.js';
@ -15,7 +15,7 @@ import { noop, ajax, o2js, js2o } from './utils.js';
import { functions } from './function.js';
import { platform } from './platform.js';
import { selection } from './selection.js';
import { stats } from './stats.js';
import { stats } from '../ui/stats.js';
import { newWidget } from './widget.js';
import { widgets } from './widgets.js';
import { updateTool } from '../mode/cam/tools.js';
@ -23,9 +23,9 @@ import { beta, version } from '../../moto/license.js';
import { space as SPACE } from '../../moto/space.js';
import { types as load } from '../../load/file.js';
import { LANG } from './lang.js';
import { LANG } from '../ui/lang.js';
import { LOCAL, SETUP, SECURE } from './main.js';
import { UI } from './ui.js';
import { UI } from '../ui/component.js';
import web from '../../moto/webui.js';

View file

@ -1,6 +1,6 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { Layers } from './layers.js';
import { Layers } from '../ui/layers.js';
import { newPoint } from '../../geo/point.js';
import { Polygon, newPolygon } from '../../geo/polygon.js';
import { Slice, newSlice, Top, newTop } from './slice.js';

View file

@ -1,172 +0,0 @@
/** 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};
});

View file

@ -1,175 +0,0 @@
/** 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 };

View file

@ -1,134 +0,0 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { api } from './api.js';
import { load } from '../../load/file.js';
import { newWidget } from './widget.js';
import { VIEWS } from './consts.js';
import { widgets } from './widgets.js';
// add frame message api listener
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);
}
});

View file

@ -4,15 +4,16 @@ import { $, $c } from '../../moto/webui.js';
import { api } from './api.js';
import { base } from '../../geo/base.js';
import { local as sdb } from '../../data/local.js';
import { slider } from './slider.js';
import { keyboard } from './keyboard.js';
import { modal } from './modal.js';
import { interact } from './interact.js';
import { slider } from '../ui/slider.js';
import { keyboard } from '../ui/keyboard.js';
import { modal } from '../ui/modal.js';
import { interact } from '../ui/interact.js';
import { space } from '../../moto/space.js';
import { LOCAL, SETUP } from './main.js';
import { VIEWS, MODES, SEED } from './consts.js';
import { beta, version } from '../../moto/license.js';
import { settings as set_ctrl } from './settings.js';
import STACKS from '../ui/stacks.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';
@ -24,8 +25,6 @@ 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 STACKS from './stacks.js';
import './tools.js';
let { CAM, SLA, FDM, LASER, DRAG, WJET, WEDM } = MODES,
{ client, catalog, platform, selection, stats } = api,

View file

@ -1,192 +0,0 @@
/** 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();

View file

@ -1,398 +0,0 @@
/** 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();

View file

@ -1,78 +0,0 @@
/** 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 };

View file

@ -1,268 +0,0 @@
/** 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

@ -1,10 +1,10 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import './frame.js';
import '../ui/frame.js';
import '../../ext/base64.js';
import STACKS from './stacks.js';
import { modal } from './modal.js';
import STACKS from '../ui/stacks.js';
import { modal } from '../ui/modal.js';
import { $ } from '../../moto/webui.js';
import { api } from './api.js';
@ -12,13 +12,13 @@ import { broker } from '../../moto/broker.js';
import { consts } from './consts.js';
import { Index } from '../../data/index.js';
import { local as dataLocal } from '../../data/local.js';
import { openFiles } from './files.js';
import { openFiles } from '../ui/files.js';
import { platform } from './platform.js';
import { selection } from './selection.js';
import { settings } from './settings.js';
import { showDevices } from './devices.js';
import { space as SPACE } from '../../moto/space.js';
import { stats } from './stats.js';
import { stats } from '../ui/stats.js';
import { noop, utils } from './utils.js';
import { version } from '../../moto/license.js';
import { Widget, newWidget } from './widget.js';

View file

@ -1,100 +0,0 @@
/** 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();

View file

@ -1,141 +0,0 @@
/** 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;
}
}

View file

@ -7,7 +7,7 @@ import { base } from '../../geo/base.js';
import { load } from '../../load/file.js';
import { space } from '../../moto/space.js';
import { Widget, newWidget } from './widget.js';
import { Packer } from './pack.js';
import { Packer } from '../ui/pack.js';
import { COLOR, MODES } from './consts.js';
import { THREE } from '../../ext/three.js';

View file

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

View file

@ -1,6 +1,6 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { Layers } from './layers.js';
import { Layers } from '../ui/layers.js';
import { newPoint } from '../../geo/point.js';
import { polygons as POLY } from '../../geo/polygons.js';

View file

@ -1,305 +0,0 @@
/** 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();

View file

@ -1,295 +0,0 @@
/** 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
});
}

View file

@ -1,181 +0,0 @@
/** 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
};

View file

@ -1,53 +0,0 @@
/** 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 './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 };

View file

@ -1,190 +0,0 @@
/** 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 };

View file

@ -1,879 +0,0 @@
/** 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;
}