fold tiny util into api, clean up modal corner cases, add more jsdoc

This commit is contained in:
Stewart Allen 2025-12-25 16:17:44 -05:00
commit bcd2449b89
11 changed files with 339 additions and 90 deletions

View file

@ -82,14 +82,36 @@ function update(clear) {
} }
} }
/**
* Update progress bar display.
* In debug mode, also shows progress status message.
* @param {number} [value=0] - Progress value (0.0 to 1.0)
* @param {string} [msg] - Optional status message to display
*/
function progress(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 = '';
}
}
}
export { export {
hide, hide,
show, show,
update update,
progress
}; };
export default { export default {
hide, hide,
show, show,
update update,
progress
}; };

View file

@ -35,7 +35,6 @@ import { stats } from './stats.js';
import { types as load } from '../../load/file.js'; import { types as load } from '../../load/file.js';
import { UI } from './inputs.js'; import { UI } from './inputs.js';
import { updateTool } from '../mode/cam/tools.js'; import { updateTool } from '../mode/cam/tools.js';
import { util as utilModule } from './util.js';
import { view as viewModule } from './viewmode.js'; import { view as viewModule } from './viewmode.js';
import { visuals } from './visuals.js'; import { visuals } from './visuals.js';
import { widgets } from '../core/widgets.js'; import { widgets } from '../core/widgets.js';
@ -43,11 +42,11 @@ import { workspace } from './workspace.js';
// environment setup // environment setup
let LOC = self.location, let LOC = self.location,
SETUP = utils.parseOpt(LOC.search.substring(1)),
SECURE = utilModule.isSecure(LOC.protocol),
LOCAL = self.debug && !SETUP.remote,
EVENT = broker, EVENT = broker,
FILES = openFiles(new Index(SETUP.d ? SETUP.d[0] : 'kiri')); SETUP = utils.parseOpt(LOC.search.substring(1)),
FILES = openFiles(new Index(SETUP.d ? SETUP.d[0] : 'kiri')),
LOCAL = self.debug && !SETUP.remote,
SECURE = isSecure(LOC.protocol);
// todo: fix in widget.js b/c front-end and back-end do not share api // todo: fix in widget.js b/c front-end and back-end do not share api
self.kiri_catalog = FILES; self.kiri_catalog = FILES;
@ -61,6 +60,27 @@ EVENT.on = (topic, listener) => {
return EVENT; return EVENT;
}; };
/**
* Check if protocol is secure (HTTPS).
* @param {string} proto - Protocol string (e.g., "https:", "http:")
* @returns {boolean} True if protocol starts with "https"
*/
function isSecure(proto) {
return proto.toLowerCase().indexOf("https") === 0;
}
/**
* Download data as a file using browser download mechanism.
* Creates temporary object URL and triggers download via hidden link click.
* @param {Blob|ArrayBuffer|string} data - Data to download
* @param {string} filename - Filename for download
*/
function download(data, filename) {
let url = WIN.URL.createObjectURL(new Blob([data], {type: "octet/stream"}));
$('mod-any').innerHTML = `<a id="_dexport_" href="${url}" download="${filename}">x</a>`;
$('_dexport_').click();
}
/** Busy state counter for tracking active operations */ /** Busy state counter for tracking active operations */
let busyVal = 0, let busyVal = 0,
/** Hover feature flag */ /** Hover feature flag */
@ -68,7 +88,10 @@ let busyVal = 0,
/** Explicit undefined for object defaults */ /** Explicit undefined for object defaults */
undef = undefined; undef = undefined;
// the big kahuna /**
* organize modules for easy use in other modules
* without having to know the paths to all of them
*/
export const api = { export const api = {
ajax, ajax,
beta, beta,
@ -132,7 +155,7 @@ export const api = {
emit(t,m,o) { return EVENT.publish(t,m,o) }, emit(t,m,o) { return EVENT.publish(t,m,o) },
bind(t,m,o) { return EVENT.bind(t,m,o) }, bind(t,m,o) { return EVENT.bind(t,m,o) },
alerts(clr) { alerts.update(clr) }, alerts(clr) { alerts.update(clr) },
import: utilModule.loadFile, import() { api.ui.load.click() },
settings: settingsUI.trigger_event settings: settingsUI.trigger_event
}, },
electron: navigator.userAgent.includes('Electron'), electron: navigator.userAgent.includes('Electron'),
@ -205,7 +228,7 @@ export const api = {
import() { api.ui.import.style.display = '' }, import() { api.ui.import.style.display = '' },
layer: visuals.set_visible_layer, layer: visuals.set_visible_layer,
local() { console.trace('deprecated') }, local() { console.trace('deprecated') },
progress: utilModule.setProgress, progress: alerts.progress,
slices: visuals.show_slices, slices: visuals.show_slices,
tools: showTools tools: showTools
}, },
@ -222,8 +245,8 @@ export const api = {
uc: UI.prefix('kiri').inputAction(settings.conf.update), uc: UI.prefix('kiri').inputAction(settings.conf.update),
ui: {}, ui: {},
util: { util: {
isSecure: utilModule.isSecure, isSecure,
download: utilModule.download, download,
ui2rec() { api.conf.update_from(...arguments) }, ui2rec() { api.conf.update_from(...arguments) },
rec2ui() { api.conf.update_fields(...arguments) }, rec2ui() { api.conf.update_fields(...arguments) },
/** /**

View file

@ -255,10 +255,10 @@ function setup_keybd_nav() {
$('rot_z_gt').onclick = () => { selection.rotate(0,0,-d * $('rot_z').value) }; $('rot_z_gt').onclick = () => { selection.rotate(0,0,-d * $('rot_z').value) };
// rendering options // rendering options
$('render-edges').onclick = () => { api.view.edges({ toggle: true }); api.conf.save() }; $('render-edges').onclick = () => { api.view.set_edges({ toggle: true }); api.conf.save() };
$('render-ghost').onclick = () => { api.view.wireframe(false, 0, api.view.is_arrange() ? 0.4 : 0.25); }; $('render-ghost').onclick = () => { api.view.set_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-wire').onclick = () => { api.view.set_wireframe(true, 0, api.space.is_dark() ? 0.25 : 0.5); };
$('render-solid').onclick = () => { api.view.wireframe(false, 0, 1); }; $('render-solid').onclick = () => { api.view.set_wireframe(false, 0, 1); };
$('mesh-export-stl').onclick = () => { settingsOps.export_objects('stl') }; $('mesh-export-stl').onclick = () => { settingsOps.export_objects('stl') };
$('mesh-export-obj').onclick = () => { settingsOps.export_objects('obj') }; $('mesh-export-obj').onclick = () => { settingsOps.export_objects('obj') };
$('mesh-merge').onclick = selection.merge; $('mesh-merge').onclick = selection.merge;

View file

@ -9,6 +9,7 @@ class ModalControl {
#ui; #ui;
#onShow; #onShow;
#onHide; #onHide;
#showing;
#initialized = false; #initialized = false;
/** /**
@ -38,7 +39,15 @@ class ModalControl {
* @returns {boolean} * @returns {boolean}
*/ */
visible() { visible() {
return this.#ui.modal.style.display === 'flex'; return modal.#ui.modal.style.display === 'flex';
}
/**
* Check if a specific modal is currently visible
* @returns {boolean}
*/
is(name) {
return modal.#showing === name;
} }
/** /**
@ -46,18 +55,21 @@ class ModalControl {
* @param {string} which - Name of the modal to show * @param {string} which - Name of the modal to show
*/ */
show(which) { show(which) {
let mod = this.#ui.modal, let mod = modal.#ui.modal,
style = mod.style, style = mod.style,
visible = this.visible(), visible = modal.visible(),
info = { pct: 0 }; info = { pct: 0 };
// hide all modals before showing another // hide all modals before showing another
Object.keys(this.#ui.modals).forEach(name => { Object.keys(modal.#ui.modals).forEach(name => {
this.#ui.modals[name].style.display = name === which ? 'flex' : ''; console.log({ name, which });
modal.#ui.modals[name].style.display = name === which ? 'flex' : '';
}); });
modal.#showing = which;
const ondone = () => { const ondone = () => {
this.#onShow?.(which); modal.#onShow?.(which);
}; };
if (visible) { if (visible) {
@ -79,10 +91,13 @@ class ModalControl {
* Hide the modal with animation * Hide the modal with animation
*/ */
hide() { hide() {
if (!this.visible()) { if (!modal.visible()) {
return; return;
} }
let mod = this.#ui.modal,
modal.#showing = undefined;
let mod = modal.#ui.modal,
style = mod.style, style = mod.style,
info = { pct: 100 }; info = { pct: 100 };
@ -92,7 +107,7 @@ class ModalControl {
onUpdate(() => { style.height = `${info.pct}%` }). onUpdate(() => { style.height = `${info.pct}%` }).
onComplete(() => { onComplete(() => {
style.display = ''; style.display = '';
this.#onHide?.(); modal.#onHide?.();
}). }).
start(); start();
} }

View file

@ -93,8 +93,8 @@ function setMode(mode, lock, then) {
platform.update_selected(); platform.update_selected();
selection.update_bounds(api.widgets.all()); selection.update_bounds(api.widgets.all());
api.conf.update_fields(); api.conf.update_fields();
// because device dialog, if showing, needs to be updated // if device dialog showing, needs to be refreshed
if (modal.visible()) { if (modal.is('setup')) {
api.show.devices(); api.show.devices();
} }
api.space.restore(null, true); api.space.restore(null, true);

View file

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

View file

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

@ -11,8 +11,26 @@ import STACKS from './stacks.js';
const { VIEWS } = consts; const { VIEWS } = consts;
const DOC = self.document; const DOC = self.document;
/** Current view mode (ARRANGE, SLICE, PREVIEW, or ANIMATE) */
let viewMode = VIEWS.ARRANGE; let viewMode = VIEWS.ARRANGE;
/**
* Set application view mode and update UI accordingly.
* Modes:
* - ARRANGE: Widget positioning and arrangement
* - SLICE: Sliced layer visualization
* - PREVIEW: Toolpath/gcode preview
* - ANIMATE: Animated toolpath playback
*
* Each mode:
* - Updates UI button states
* - Clears selections and bounds
* - Shows/hides relevant tools and sliders
* - Configures widget visibility and rendering
* - Emits 'view.set' event
*
* @param {number} mode - View mode constant from VIEWS
*/
function setViewMode(mode) { function setViewMode(mode) {
const isCAM = settings.mode() === 'CAM'; const isCAM = settings.mode() === 'CAM';
viewMode = mode; viewMode = mode;
@ -37,23 +55,23 @@ function setViewMode(mode) {
api.visuals.set_visible_layer(); api.visuals.set_visible_layer();
api.visuals.set_widget_visibility(true); api.visuals.set_widget_visibility(true);
api.widgets.setOpacity(1); api.widgets.setOpacity(1);
api.view.edges(api.local.getBoolean('model.edges')); api.view.set_edges(api.local.getBoolean('model.edges'));
break; break;
case VIEWS.SLICE: case VIEWS.SLICE:
$('act-slice').classList.add('selected'); $('act-slice').classList.add('selected');
api.visuals.update_speeds(); api.visuals.update_speeds();
api.visuals.update_slider_max(); api.visuals.update_slider_max();
api.visuals.set_widget_visibility(true); api.visuals.set_widget_visibility(true);
!isCAM && api.view.edges(false); !isCAM && api.view.set_edges(false);
break; break;
case VIEWS.PREVIEW: case VIEWS.PREVIEW:
$('act-preview').classList.add('selected'); $('act-preview').classList.add('selected');
api.visuals.set_widget_visibility(true); api.visuals.set_widget_visibility(true);
!isCAM && api.view.edges(false); !isCAM && api.view.set_edges(false);
break; break;
case VIEWS.ANIMATE: case VIEWS.ANIMATE:
$('act-animate').classList.add('selected'); $('act-animate').classList.add('selected');
!isCAM && api.view.edges(false); !isCAM && api.view.set_edges(false);
break; break;
default: default:
console.log("invalid view mode: "+mode); console.log("invalid view mode: "+mode);
@ -63,15 +81,29 @@ function setViewMode(mode) {
DOC.activeElement.blur(); DOC.activeElement.blur();
} }
/**
* View mode management API.
* Provides getters/setters and mode checking utilities.
*/
export const view = { export const view = {
/** Get current view mode */
get() { return viewMode }, get() { return viewMode },
/** Set view mode */
set() { setViewMode(...arguments) }, set() { setViewMode(...arguments) },
/** Switch to arrange mode */
set_arrange() { setViewMode(VIEWS.ARRANGE) }, set_arrange() { setViewMode(VIEWS.ARRANGE) },
/** Switch to slice mode */
set_slice() { setViewMode(VIEWS.SLICE) }, set_slice() { setViewMode(VIEWS.SLICE) },
/** Switch to preview mode */
set_preview() { setViewMode(VIEWS.PREVIEW) }, set_preview() { setViewMode(VIEWS.PREVIEW) },
/** Switch to animate mode */
set_animate() { setViewMode(VIEWS.ANIMATE) }, set_animate() { setViewMode(VIEWS.ANIMATE) },
/** Check if in arrange mode */
is_arrange() { return viewMode === VIEWS.ARRANGE }, is_arrange() { return viewMode === VIEWS.ARRANGE },
/** Check if in slice mode */
is_slice() { return viewMode === VIEWS.SLICE }, is_slice() { return viewMode === VIEWS.SLICE },
/** Check if in preview mode */
is_preview() { return viewMode === VIEWS.PREVIEW }, is_preview() { return viewMode === VIEWS.PREVIEW },
/** Check if in animate mode */
is_animate() { return viewMode === VIEWS.ANIMATE } is_animate() { return viewMode === VIEWS.ANIMATE }
}; };

View file

@ -8,15 +8,35 @@ import STACKS from './stacks.js';
const { COLOR, VIEWS } = consts; const { COLOR, VIEWS } = consts;
// Note: viewMode is imported dynamically via api.view.get() /**
* Constrain value to min/max bounds.
* @param {number} v - Value to constrain
* @param {number} min - Minimum value
* @param {number} max - Maximum value
* @returns {number} Constrained value
*/
function bound(v,min,max) { function bound(v,min,max) {
return Math.max(min,Math.min(max,v)); return Math.max(min,Math.min(max,v));
} }
/**
* Get unit scale factor for coordinate conversion.
* Returns 25.4 for CAM mode with inches, 1.0 otherwise (mm).
* @returns {number} Unit scale factor
*/
function unitScale() { function unitScale() {
return api.mode.is_cam() && settings.ctrl().units === 'in' ? 25.4 : 1; return api.mode.is_cam() && settings.ctrl().units === 'in' ? 25.4 : 1;
} }
/**
* Update speed color legend for preview mode.
* Shows color-coded speed visualization in preview mode for FDM.
* Generates 20 color swatches from worker and displays in UI.
* Only visible in PREVIEW mode for FDM (not SLA/LASER) when enabled.
* Emits 'preview.speeds' event with min/max speeds.
* @param {number} [maxSpeed] - Maximum speed value
* @param {number} [minSpeed] - Minimum speed value
*/
function updateSpeeds(maxSpeed, minSpeed) { function updateSpeeds(maxSpeed, minSpeed) {
const { ui } = api; const { ui } = api;
const viewMode = api.view.get(); const viewMode = api.view.get();
@ -51,6 +71,11 @@ function updateSpeeds(maxSpeed, minSpeed) {
} }
} }
/**
* Update slider visual position and sync settings.
* Updates slider thumb positions based on current range.
* Synchronizes configuration fields from range values.
*/
function updateSlider() { function updateSlider() {
const { lo, hi, max } = api.slider.getRange(); const { lo, hi, max } = api.slider.getRange();
if (max > 0) { if (max > 0) {
@ -59,6 +84,13 @@ function updateSlider() {
api.conf.update_fields_from_range(); api.conf.update_fields_from_range();
} }
/**
* Set visible layer range and update visualization.
* Bounds values to valid range and updates slider.
* Triggers slice visualization update.
* @param {number} [h] - High layer (defaults to current hi)
* @param {number} [l] - Low layer (defaults to current lo)
*/
function setVisibleLayer(h, l) { function setVisibleLayer(h, l) {
const { lo, hi, max } = api.slider.getRange(); const { lo, hi, max } = api.slider.getRange();
h = h >= 0 ? h : hi; h = h >= 0 ? h : hi;
@ -70,11 +102,25 @@ function setVisibleLayer(h, l) {
showSlices(); showSlices();
} }
/**
* Set wireframe rendering mode for all widgets.
* Updates 3D scene after applying to all widgets.
* @param {boolean} bool - Enable/disable wireframe
* @param {number} [color] - Optional wireframe color
* @param {number} [opacity] - Optional wireframe opacity
*/
function setWireframe(bool, color, opacity) { function setWireframe(bool, color, opacity) {
api.widgets.each(function(w) { w.setWireframe(bool, color, opacity) }); api.widgets.each(function(w) { w.setWireframe(bool, color, opacity) });
SPACE.update(); SPACE.update();
} }
/**
* Toggle or set edge rendering for all widgets.
* If bool.toggle is truthy, toggles the current state.
* Otherwise sets edge visibility to bool value.
* Persists state to local storage and updates scene.
* @param {boolean|object} bool - Enable/disable edges, or {toggle: true} to toggle
*/
function setEdges(bool) { function setEdges(bool) {
if (bool && bool.toggle) { if (bool && bool.toggle) {
api.local.toggle('model.edges'); api.local.toggle('model.edges');
@ -86,6 +132,12 @@ function setEdges(bool) {
SPACE.update(); SPACE.update();
} }
/**
* Update slider maximum from stack height.
* Sets slider max to tallest stack layer - 1.
* If set=true or current hi exceeds new max, resets range to show top layer.
* @param {boolean} [set] - Force reset range to max
*/
function updateSliderMax(set) { function updateSliderMax(set) {
let max = STACKS.getRange().tallest - 1; let max = STACKS.getRange().tallest - 1;
api.slider.setMax(max); api.slider.setMax(max);
@ -96,6 +148,10 @@ function updateSliderMax(set) {
} }
} }
/**
* Hide slice visualization and restore widget rendering.
* Clears stack display, restores model opacity, disables wireframe.
*/
function hideSlices() { function hideSlices() {
STACKS.clear(); STACKS.clear();
api.widgets.setOpacity(COLOR.model_opacity); api.widgets.setOpacity(COLOR.model_opacity);
@ -104,6 +160,10 @@ function hideSlices() {
}); });
} }
/**
* Show or hide all widgets in 3D scene.
* @param {boolean} bool - True to show, false to hide
*/
function setWidgetVisibility(bool) { function setWidgetVisibility(bool) {
api.widgets.each(w => { api.widgets.each(w => {
if (bool) { if (bool) {
@ -115,9 +175,13 @@ function setWidgetVisibility(bool) {
} }
/** /**
* hide or show slice-layers and their sub-elements * Show slice layer visualization.
* * Only works in SLICE/PREVIEW/ANIMATE views (not ARRANGE).
* @param {number} [layer] * Updates slider range to show specified layer(s).
* If no layer specified, shows current hi layer.
* Expands range downward if needed (newLo = min(layer, lo)).
* Updates slider position and triggers stack visualization.
* @param {number|string} [layer] - Layer to show (defaults to current hi)
*/ */
function showSlices(layer) { function showSlices(layer) {
const viewMode = api.view.get(); const viewMode = api.view.get();
@ -146,15 +210,26 @@ function showSlices(layer) {
SPACE.update(); SPACE.update();
} }
/**
* Show layer slider UI element.
*/
function showSlider() { function showSlider() {
api.slider.show(); api.slider.show();
} }
/**
* Hide layer slider and speed legend UI elements.
*/
function hideSlider() { function hideSlider() {
api.slider.hide(); api.slider.hide();
api.ui.speeds.style.display = 'none'; api.ui.speeds.style.display = 'none';
} }
/**
* Synchronize stack label visibility with saved preferences.
* Reads visibility state from settings.labels for current mode/view combination.
* Applies visibility to all stack labels.
*/
function updateStackLabelState() { function updateStackLabelState() {
const settings = api.conf.get(); const settings = api.conf.get();
// match label checkboxes to preference // match label checkboxes to preference
@ -165,8 +240,8 @@ function updateStackLabelState() {
} }
export const visuals = { export const visuals = {
wireframe: setWireframe, set_wireframe: setWireframe,
edges: setEdges, set_edges: setEdges,
unit_scale: unitScale, unit_scale: unitScale,
update_speeds: updateSpeeds, update_speeds: updateSpeeds,
update_slider: updateSlider, update_slider: updateSlider,

View file

@ -17,13 +17,25 @@ const LOC = self.location;
const SETUP = parseOpt(LOC.search.substring(1)); const SETUP = parseOpt(LOC.search.substring(1));
const FILES = openFiles(new Index(SETUP.d ? SETUP.d[0] : 'kiri')); const FILES = openFiles(new Index(SETUP.d ? SETUP.d[0] : 'kiri'));
/** Auto-save timer handle */
let autoSaveTimer = null; let autoSaveTimer = null;
/**
* Reload the application page.
* Emits 'reload' event and defers reload until busy count reaches zero.
*/
function reload() { function reload() {
api.event.emit('reload'); api.event.emit('reload');
do_reload(100); do_reload(100);
} }
/**
* Perform page reload after ensuring async operations complete.
* Checks busy state and defers reload until idle.
* Retries with exponential backoff (100ms -> 250ms).
* @param {number} [time=100] - Initial delay in milliseconds
* @private
*/
function do_reload(time) { function do_reload(time) {
// allow time for async saves to complete and busy to to to zero // allow time for async saves to complete and busy to to to zero
setTimeout(() => { setTimeout(() => {
@ -36,6 +48,11 @@ function do_reload(time) {
}, time || 100); }, time || 100);
} }
/**
* Trigger delayed auto-save if enabled.
* Debounces saves with 1-second delay.
* Only saves if controller.autoSave is enabled.
*/
function auto_save() { function auto_save() {
if (!settings.ctrl().autoSave) { if (!settings.ctrl().autoSave) {
return; return;
@ -46,6 +63,14 @@ function auto_save() {
}, 1000); }, 1000);
} }
/**
* Set camera focus on widgets or point.
* If point provided, focuses on that point.
* Otherwise calculates centroid of selection (or all widgets if sel undefined/empty).
* For CAM indexed mode, uses Z=0 focus height. Otherwise uses half of top_z.
* @param {Widget|Array<Widget>} [sel] - Widget(s) to focus on (defaults to all)
* @param {object} [point] - Optional point {x, y, z} to focus on directly
*/
function setFocus(sel, point) { function setFocus(sel, point) {
if (point) { if (point) {
SPACE.platform.setCenter(point.x, point.z, point.y); SPACE.platform.setCenter(point.x, point.z, point.y);
@ -76,6 +101,16 @@ function setFocus(sel, point) {
SPACE.view.setFocus(new THREE.Vector3(pos.x, focus_z, -pos.y)); SPACE.view.setFocus(new THREE.Vector3(pos.x, focus_z, -pos.y));
} }
/**
* Save workspace state to local storage.
* Saves:
* - Configuration settings
* - Widget list and metadata
* - Widget positions and states
* Removes stale widget data for deleted widgets.
* Shows alert unless quiet=true.
* @param {boolean} [quiet] - Suppress "workspace saved" alert
*/
function saveWorkspace(quiet) { function saveWorkspace(quiet) {
api.conf.save(); api.conf.save();
const newWidgets = []; const newWidgets = [];
@ -100,6 +135,18 @@ function saveWorkspace(quiet) {
} }
} }
/**
* Restore workspace from local storage.
* Restores:
* - Configuration settings
* - Camera view position
* - All saved widgets from storage
* Clears existing platform widgets unless skip_widget_load=true.
* Calls ondone callback when complete.
* @param {Function} [ondone] - Callback when restore completes
* @param {boolean} [skip_widget_load] - Skip loading widgets (config only)
* @returns {boolean} True if widgets were queued for loading
*/
function restoreWorkspace(ondone, skip_widget_load) { function restoreWorkspace(ondone, skip_widget_load) {
let newset = api.conf.restore(false), let newset = api.conf.restore(false),
camera = newset.controller.view, camera = newset.controller.view,
@ -154,6 +201,10 @@ function restoreWorkspace(ondone, skip_widget_load) {
return toload.length > 0; return toload.length > 0;
} }
/**
* Clear all widgets from workspace.
* Clears worker cache/memory, selects all widgets, and deletes them.
*/
function clearWorkspace() { function clearWorkspace() {
// free up worker cache/mem // free up worker cache/mem
api.client.clear(); api.client.clear();
@ -161,6 +212,10 @@ function clearWorkspace() {
platform.delete(selection.meshes()); platform.delete(selection.meshes());
} }
/**
* Check if dark mode is enabled.
* @returns {boolean} True if dark mode active
*/
function is_dark() { function is_dark() {
return settings.ctrl().dark; return settings.ctrl().dark;
} }

View file

@ -23,6 +23,7 @@ export async function cam_slice(settings, widget, onupdate, ondone) {
// get tab widgets // get tab widgets
let tabW = widget.group.filter(w => w != widget); let tabW = widget.group.filter(w => w != widget);
// merge overlapping tabs // merge overlapping tabs
for (let i=0; i<tabW.length; i++) { for (let i=0; i<tabW.length; i++) {
for (let j=i+1; j<tabW.length; j++) { for (let j=i+1; j<tabW.length; j++) {