diff --git a/src/kiri/app/alerts.js b/src/kiri/app/alerts.js
index a54f1dac..6fa72f1f 100644
--- a/src/kiri/app/alerts.js
+++ b/src/kiri/app/alerts.js
@@ -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 {
hide,
show,
- update
+ update,
+ progress
};
export default {
hide,
show,
- update
+ update,
+ progress
};
diff --git a/src/kiri/app/api.js b/src/kiri/app/api.js
index 48d11c2e..d87f99d6 100644
--- a/src/kiri/app/api.js
+++ b/src/kiri/app/api.js
@@ -35,7 +35,6 @@ import { stats } from './stats.js';
import { types as load } from '../../load/file.js';
import { UI } from './inputs.js';
import { updateTool } from '../mode/cam/tools.js';
-import { util as utilModule } from './util.js';
import { view as viewModule } from './viewmode.js';
import { visuals } from './visuals.js';
import { widgets } from '../core/widgets.js';
@@ -43,11 +42,11 @@ import { workspace } from './workspace.js';
// environment setup
let LOC = self.location,
- SETUP = utils.parseOpt(LOC.search.substring(1)),
- SECURE = utilModule.isSecure(LOC.protocol),
- LOCAL = self.debug && !SETUP.remote,
EVENT = broker,
- FILES = openFiles(new Index(SETUP.d ? SETUP.d[0] : 'kiri'));
+ SETUP = utils.parseOpt(LOC.search.substring(1)),
+ FILES = openFiles(new Index(SETUP.d ? SETUP.d[0] : 'kiri')),
+ LOCAL = self.debug && !SETUP.remote,
+ SECURE = isSecure(LOC.protocol);
// todo: fix in widget.js b/c front-end and back-end do not share api
self.kiri_catalog = FILES;
@@ -61,6 +60,27 @@ EVENT.on = (topic, listener) => {
return EVENT;
};
+/**
+ * Check if protocol is secure (HTTPS).
+ * @param {string} proto - Protocol string (e.g., "https:", "http:")
+ * @returns {boolean} True if protocol starts with "https"
+ */
+function isSecure(proto) {
+ return proto.toLowerCase().indexOf("https") === 0;
+}
+
+/**
+ * Download data as a file using browser download mechanism.
+ * Creates temporary object URL and triggers download via hidden link click.
+ * @param {Blob|ArrayBuffer|string} data - Data to download
+ * @param {string} filename - Filename for download
+ */
+function download(data, filename) {
+ let url = WIN.URL.createObjectURL(new Blob([data], {type: "octet/stream"}));
+ $('mod-any').innerHTML = `x`;
+ $('_dexport_').click();
+}
+
/** Busy state counter for tracking active operations */
let busyVal = 0,
/** Hover feature flag */
@@ -68,7 +88,10 @@ let busyVal = 0,
/** Explicit undefined for object defaults */
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 = {
ajax,
beta,
@@ -132,7 +155,7 @@ export const api = {
emit(t,m,o) { return EVENT.publish(t,m,o) },
bind(t,m,o) { return EVENT.bind(t,m,o) },
alerts(clr) { alerts.update(clr) },
- import: utilModule.loadFile,
+ import() { api.ui.load.click() },
settings: settingsUI.trigger_event
},
electron: navigator.userAgent.includes('Electron'),
@@ -205,7 +228,7 @@ export const api = {
import() { api.ui.import.style.display = '' },
layer: visuals.set_visible_layer,
local() { console.trace('deprecated') },
- progress: utilModule.setProgress,
+ progress: alerts.progress,
slices: visuals.show_slices,
tools: showTools
},
@@ -222,8 +245,8 @@ export const api = {
uc: UI.prefix('kiri').inputAction(settings.conf.update),
ui: {},
util: {
- isSecure: utilModule.isSecure,
- download: utilModule.download,
+ isSecure,
+ download,
ui2rec() { api.conf.update_from(...arguments) },
rec2ui() { api.conf.update_fields(...arguments) },
/**
diff --git a/src/kiri/app/init/sync.js b/src/kiri/app/init/sync.js
index 75f3b554..90a4b842 100644
--- a/src/kiri/app/init/sync.js
+++ b/src/kiri/app/init/sync.js
@@ -255,10 +255,10 @@ function setup_keybd_nav() {
$('rot_z_gt').onclick = () => { selection.rotate(0,0,-d * $('rot_z').value) };
// rendering options
- $('render-edges').onclick = () => { api.view.edges({ toggle: true }); api.conf.save() };
- $('render-ghost').onclick = () => { api.view.wireframe(false, 0, api.view.is_arrange() ? 0.4 : 0.25); };
- $('render-wire').onclick = () => { api.view.wireframe(true, 0, api.space.is_dark() ? 0.25 : 0.5); };
- $('render-solid').onclick = () => { api.view.wireframe(false, 0, 1); };
+ $('render-edges').onclick = () => { api.view.set_edges({ toggle: true }); api.conf.save() };
+ $('render-ghost').onclick = () => { api.view.set_wireframe(false, 0, api.view.is_arrange() ? 0.4 : 0.25); };
+ $('render-wire').onclick = () => { api.view.set_wireframe(true, 0, api.space.is_dark() ? 0.25 : 0.5); };
+ $('render-solid').onclick = () => { api.view.set_wireframe(false, 0, 1); };
$('mesh-export-stl').onclick = () => { settingsOps.export_objects('stl') };
$('mesh-export-obj').onclick = () => { settingsOps.export_objects('obj') };
$('mesh-merge').onclick = selection.merge;
diff --git a/src/kiri/app/modal.js b/src/kiri/app/modal.js
index 6dbf4013..2dacd74a 100644
--- a/src/kiri/app/modal.js
+++ b/src/kiri/app/modal.js
@@ -9,6 +9,7 @@ class ModalControl {
#ui;
#onShow;
#onHide;
+ #showing;
#initialized = false;
/**
@@ -38,7 +39,15 @@ class ModalControl {
* @returns {boolean}
*/
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
*/
show(which) {
- let mod = this.#ui.modal,
+ let mod = modal.#ui.modal,
style = mod.style,
- visible = this.visible(),
+ visible = modal.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' : '';
+ Object.keys(modal.#ui.modals).forEach(name => {
+ console.log({ name, which });
+ modal.#ui.modals[name].style.display = name === which ? 'flex' : '';
});
+ modal.#showing = which;
+
const ondone = () => {
- this.#onShow?.(which);
+ modal.#onShow?.(which);
};
if (visible) {
@@ -79,10 +91,13 @@ class ModalControl {
* Hide the modal with animation
*/
hide() {
- if (!this.visible()) {
+ if (!modal.visible()) {
return;
}
- let mod = this.#ui.modal,
+
+ modal.#showing = undefined;
+
+ let mod = modal.#ui.modal,
style = mod.style,
info = { pct: 100 };
@@ -92,7 +107,7 @@ class ModalControl {
onUpdate(() => { style.height = `${info.pct}%` }).
onComplete(() => {
style.display = '';
- this.#onHide?.();
+ modal.#onHide?.();
}).
start();
}
diff --git a/src/kiri/app/mode.js b/src/kiri/app/mode.js
index 40b4c394..22caa361 100644
--- a/src/kiri/app/mode.js
+++ b/src/kiri/app/mode.js
@@ -93,8 +93,8 @@ function setMode(mode, lock, then) {
platform.update_selected();
selection.update_bounds(api.widgets.all());
api.conf.update_fields();
- // because device dialog, if showing, needs to be updated
- if (modal.visible()) {
+ // if device dialog showing, needs to be refreshed
+ if (modal.is('setup')) {
api.show.devices();
}
api.space.restore(null, true);
diff --git a/src/kiri/app/tools.js b/src/kiri/app/tools.js
index 3ec7bac6..07df927f 100644
--- a/src/kiri/app/tools.js
+++ b/src/kiri/app/tools.js
@@ -1,20 +1,39 @@
/** Copyright Stewart Allen -- All Rights Reserved */
+/**
+ * Face selection tools for mesh manipulation.
+ * Provides interactive tools for:
+ * - Lay flat: Rotate selected face to be parallel with build plate
+ * - Left align: Rotate to align face normal with -X axis
+ * - Face up: Select face normal for custom operations
+ * - Camera focus: Select point for camera focus
+ *
+ * Tools use hover mode to display visual indicator on face under cursor.
+ * User clicks face to apply operation. ESC to cancel.
+ */
+
import { api } from './api.js';
import { THREE } from '../../ext/three.js';
import { space } from '../../moto/space.js';
const { Vector3, Quaternion } = THREE;
+/** X-axis unit vector for rotation calculations */
const XAXIS = new THREE.Vector3(1,0,0);
+
+/** Z-axis unit vector for rotation calculations */
const ZAXIS = new THREE.Vector3(0,0,1);
-// circle
+/** Circle mesh geometry for face indicator */
let pgeo = new THREE.CircleGeometry(8, 30);
+
+/** Semi-transparent material for face indicator */
let pmat = new THREE.MeshBasicMaterial({color: 0xddeeff, opacity: 0.6, transparent: true});
+
+/** Combined mesh for face indicator (circle + outline) */
let pmesh = new THREE.Mesh(pgeo, pmat);
-// circle outline
+// Build circle outline from geometry indices
let pi = pgeo.index.array;
let pp = pgeo.attributes.position.array;
let np = [];
@@ -36,19 +55,39 @@ let wmat = new THREE.LineBasicMaterial({ color: 0x6699cc });
let wmesh = new THREE.LineSegments(wgeo, wmat);
pmesh.add(wmesh);
+/** Alert handle for "ESC to end" message */
let alert;
+
+/** Last object (widget mesh) the indicator was attached to */
let lastobj;
+
+/** Last face that was hovered over */
let lastface;
+
+/** Whether a tool is currently active */
let enabled = false;
+
+/** Name of current operation for display */
let opName = 'lay flat';
+
+/** Callback to execute when face is selected */
let onDone = onLayFlatSelect;
+/**
+ * Lay flat operation: Rotate widget so selected face is parallel to build plate.
+ * Calculates quaternion to align face normal with -Z axis (down).
+ */
function onLayFlatSelect() {
let q = new Quaternion().setFromUnitVectors(lastface.normal, new Vector3(0,0,-1));
api.selection.rotate(q);
// endIt();
}
+/**
+ * Left align operation: Rotate widget so selected face normal aligns with -X axis.
+ * Projects face normal onto XY plane and calculates Z-axis rotation.
+ * Used for orienting parts for belt printers or side printing.
+ */
function onLeftySelect() {
// Project the face normal onto the XY plane (ignore Z component)
let projectedNormal = new Vector3(lastface.normal.x, lastface.normal.y, 0).normalize();
@@ -67,11 +106,21 @@ function onLeftySelect() {
api.selection.rotate(q);
}
+/**
+ * Face select operation: Emit face normal for custom handling.
+ * Emits 'tool.mesh.face-normal' event with selected face normal.
+ */
function onFaceUpSelect() {
api.event.emit('tool.mesh.face-normal', lastface.normal);
// endIt();
}
+/**
+ * Start interactive face selection tool.
+ * Enables hover mode, displays alert, and begins tracking mouse.
+ * If already enabled, ends the current operation.
+ * Cannot start if another hover mode is active.
+ */
function startIt() {
if (enabled) {
endIt();
@@ -86,6 +135,10 @@ function startIt() {
alert = api.show.alert(`[ESC] to end ${opName}`, 600000);
}
+/**
+ * End current face selection tool.
+ * Disables hover mode, hides alert, and cleans up indicator mesh.
+ */
function endIt() {
if (enabled) {
api.hide.alert(alert);
@@ -96,6 +149,9 @@ function endIt() {
}
}
+/**
+ * Remove face indicator mesh from scene.
+ */
function cleanup() {
if (lastobj) {
lastobj.remove(pmesh);
@@ -105,30 +161,52 @@ function cleanup() {
api.event.on('key.esc', endIt);
+/**
+ * Start camera focus tool.
+ * User clicks face to set camera focus point.
+ * @param {Function} fn - Callback to execute with focus point
+ */
function startFocus(fn) {
opName = 'camera focus';
onDone = fn;
startIt();
}
+/**
+ * Start face selection tool.
+ * User clicks face to select its normal vector.
+ */
function startFaceUp() {
opName = 'face select';
onDone = onFaceUpSelect;
startIt();
}
+/**
+ * Start lay flat tool.
+ * User clicks face to rotate widget so that face is parallel to build plate.
+ */
function startLayFlat() {
opName = 'lay flat';
onDone = onLayFlatSelect;
startIt();
}
+/**
+ * Start left align tool.
+ * User clicks face to align its normal with -X axis.
+ */
function startLeftAlign() {
opName = 'align y axis';
onDone = onLeftySelect;
startIt();
}
+/**
+ * Scale face indicator based on camera distance.
+ * For camera focus, scales inversely (closer = smaller).
+ * For other tools, scales with distance for consistent visual size.
+ */
function scale() {
let cam = space.internals().camera;
let dist = cam.position.distanceTo(pmesh.position);
diff --git a/src/kiri/app/util.js b/src/kiri/app/util.js
deleted file mode 100644
index 1d022cc1..00000000
--- a/src/kiri/app/util.js
+++ /dev/null
@@ -1,52 +0,0 @@
-/** Copyright Stewart Allen -- 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 = `x`;
- $('_dexport_').click();
-}
-
-export const util = {
- isSecure,
- download: downloadBlob,
- setProgress,
- loadFile
-};
diff --git a/src/kiri/app/viewmode.js b/src/kiri/app/viewmode.js
index c7f3731a..35da8ee0 100644
--- a/src/kiri/app/viewmode.js
+++ b/src/kiri/app/viewmode.js
@@ -11,8 +11,26 @@ import STACKS from './stacks.js';
const { VIEWS } = consts;
const DOC = self.document;
+/** Current view mode (ARRANGE, SLICE, PREVIEW, or ANIMATE) */
let viewMode = VIEWS.ARRANGE;
+/**
+ * Set application view mode and update UI accordingly.
+ * Modes:
+ * - ARRANGE: Widget positioning and arrangement
+ * - SLICE: Sliced layer visualization
+ * - PREVIEW: Toolpath/gcode preview
+ * - ANIMATE: Animated toolpath playback
+ *
+ * Each mode:
+ * - Updates UI button states
+ * - Clears selections and bounds
+ * - Shows/hides relevant tools and sliders
+ * - Configures widget visibility and rendering
+ * - Emits 'view.set' event
+ *
+ * @param {number} mode - View mode constant from VIEWS
+ */
function setViewMode(mode) {
const isCAM = settings.mode() === 'CAM';
viewMode = mode;
@@ -37,23 +55,23 @@ function setViewMode(mode) {
api.visuals.set_visible_layer();
api.visuals.set_widget_visibility(true);
api.widgets.setOpacity(1);
- api.view.edges(api.local.getBoolean('model.edges'));
+ api.view.set_edges(api.local.getBoolean('model.edges'));
break;
case VIEWS.SLICE:
$('act-slice').classList.add('selected');
api.visuals.update_speeds();
api.visuals.update_slider_max();
api.visuals.set_widget_visibility(true);
- !isCAM && api.view.edges(false);
+ !isCAM && api.view.set_edges(false);
break;
case VIEWS.PREVIEW:
$('act-preview').classList.add('selected');
api.visuals.set_widget_visibility(true);
- !isCAM && api.view.edges(false);
+ !isCAM && api.view.set_edges(false);
break;
case VIEWS.ANIMATE:
$('act-animate').classList.add('selected');
- !isCAM && api.view.edges(false);
+ !isCAM && api.view.set_edges(false);
break;
default:
console.log("invalid view mode: "+mode);
@@ -63,15 +81,29 @@ function setViewMode(mode) {
DOC.activeElement.blur();
}
+/**
+ * View mode management API.
+ * Provides getters/setters and mode checking utilities.
+ */
export const view = {
+ /** Get current view mode */
get() { return viewMode },
+ /** Set view mode */
set() { setViewMode(...arguments) },
+ /** Switch to arrange mode */
set_arrange() { setViewMode(VIEWS.ARRANGE) },
+ /** Switch to slice mode */
set_slice() { setViewMode(VIEWS.SLICE) },
+ /** Switch to preview mode */
set_preview() { setViewMode(VIEWS.PREVIEW) },
+ /** Switch to animate mode */
set_animate() { setViewMode(VIEWS.ANIMATE) },
+ /** Check if in arrange mode */
is_arrange() { return viewMode === VIEWS.ARRANGE },
+ /** Check if in slice mode */
is_slice() { return viewMode === VIEWS.SLICE },
+ /** Check if in preview mode */
is_preview() { return viewMode === VIEWS.PREVIEW },
+ /** Check if in animate mode */
is_animate() { return viewMode === VIEWS.ANIMATE }
};
diff --git a/src/kiri/app/visuals.js b/src/kiri/app/visuals.js
index a16166e8..d9ef410f 100644
--- a/src/kiri/app/visuals.js
+++ b/src/kiri/app/visuals.js
@@ -8,15 +8,35 @@ import STACKS from './stacks.js';
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) {
return Math.max(min,Math.min(max,v));
}
+/**
+ * Get unit scale factor for coordinate conversion.
+ * Returns 25.4 for CAM mode with inches, 1.0 otherwise (mm).
+ * @returns {number} Unit scale factor
+ */
function unitScale() {
return api.mode.is_cam() && settings.ctrl().units === 'in' ? 25.4 : 1;
}
+/**
+ * Update speed color legend for preview mode.
+ * Shows color-coded speed visualization in preview mode for FDM.
+ * Generates 20 color swatches from worker and displays in UI.
+ * Only visible in PREVIEW mode for FDM (not SLA/LASER) when enabled.
+ * Emits 'preview.speeds' event with min/max speeds.
+ * @param {number} [maxSpeed] - Maximum speed value
+ * @param {number} [minSpeed] - Minimum speed value
+ */
function updateSpeeds(maxSpeed, minSpeed) {
const { ui } = api;
const viewMode = api.view.get();
@@ -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() {
const { lo, hi, max } = api.slider.getRange();
if (max > 0) {
@@ -59,6 +84,13 @@ function updateSlider() {
api.conf.update_fields_from_range();
}
+/**
+ * Set visible layer range and update visualization.
+ * Bounds values to valid range and updates slider.
+ * Triggers slice visualization update.
+ * @param {number} [h] - High layer (defaults to current hi)
+ * @param {number} [l] - Low layer (defaults to current lo)
+ */
function setVisibleLayer(h, l) {
const { lo, hi, max } = api.slider.getRange();
h = h >= 0 ? h : hi;
@@ -70,11 +102,25 @@ function setVisibleLayer(h, l) {
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) {
api.widgets.each(function(w) { w.setWireframe(bool, color, opacity) });
SPACE.update();
}
+/**
+ * Toggle or set edge rendering for all widgets.
+ * If bool.toggle is truthy, toggles the current state.
+ * Otherwise sets edge visibility to bool value.
+ * Persists state to local storage and updates scene.
+ * @param {boolean|object} bool - Enable/disable edges, or {toggle: true} to toggle
+ */
function setEdges(bool) {
if (bool && bool.toggle) {
api.local.toggle('model.edges');
@@ -86,6 +132,12 @@ function setEdges(bool) {
SPACE.update();
}
+/**
+ * Update slider maximum from stack height.
+ * Sets slider max to tallest stack layer - 1.
+ * If set=true or current hi exceeds new max, resets range to show top layer.
+ * @param {boolean} [set] - Force reset range to max
+ */
function updateSliderMax(set) {
let max = STACKS.getRange().tallest - 1;
api.slider.setMax(max);
@@ -96,6 +148,10 @@ function updateSliderMax(set) {
}
}
+/**
+ * Hide slice visualization and restore widget rendering.
+ * Clears stack display, restores model opacity, disables wireframe.
+ */
function hideSlices() {
STACKS.clear();
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) {
api.widgets.each(w => {
if (bool) {
@@ -115,9 +175,13 @@ function setWidgetVisibility(bool) {
}
/**
- * hide or show slice-layers and their sub-elements
- *
- * @param {number} [layer]
+ * Show slice layer visualization.
+ * Only works in SLICE/PREVIEW/ANIMATE views (not ARRANGE).
+ * Updates slider range to show specified layer(s).
+ * If no layer specified, shows current hi layer.
+ * Expands range downward if needed (newLo = min(layer, lo)).
+ * Updates slider position and triggers stack visualization.
+ * @param {number|string} [layer] - Layer to show (defaults to current hi)
*/
function showSlices(layer) {
const viewMode = api.view.get();
@@ -146,15 +210,26 @@ function showSlices(layer) {
SPACE.update();
}
+/**
+ * Show layer slider UI element.
+ */
function showSlider() {
api.slider.show();
}
+/**
+ * Hide layer slider and speed legend UI elements.
+ */
function hideSlider() {
api.slider.hide();
api.ui.speeds.style.display = 'none';
}
+/**
+ * Synchronize stack label visibility with saved preferences.
+ * Reads visibility state from settings.labels for current mode/view combination.
+ * Applies visibility to all stack labels.
+ */
function updateStackLabelState() {
const settings = api.conf.get();
// match label checkboxes to preference
@@ -165,8 +240,8 @@ function updateStackLabelState() {
}
export const visuals = {
- wireframe: setWireframe,
- edges: setEdges,
+ set_wireframe: setWireframe,
+ set_edges: setEdges,
unit_scale: unitScale,
update_speeds: updateSpeeds,
update_slider: updateSlider,
diff --git a/src/kiri/app/workspace.js b/src/kiri/app/workspace.js
index f5df2ea1..6c97faa4 100644
--- a/src/kiri/app/workspace.js
+++ b/src/kiri/app/workspace.js
@@ -17,13 +17,25 @@ const LOC = self.location;
const SETUP = parseOpt(LOC.search.substring(1));
const FILES = openFiles(new Index(SETUP.d ? SETUP.d[0] : 'kiri'));
+/** Auto-save timer handle */
let autoSaveTimer = null;
+/**
+ * Reload the application page.
+ * Emits 'reload' event and defers reload until busy count reaches zero.
+ */
function reload() {
api.event.emit('reload');
do_reload(100);
}
+/**
+ * Perform page reload after ensuring async operations complete.
+ * Checks busy state and defers reload until idle.
+ * Retries with exponential backoff (100ms -> 250ms).
+ * @param {number} [time=100] - Initial delay in milliseconds
+ * @private
+ */
function do_reload(time) {
// allow time for async saves to complete and busy to to to zero
setTimeout(() => {
@@ -36,6 +48,11 @@ function do_reload(time) {
}, time || 100);
}
+/**
+ * Trigger delayed auto-save if enabled.
+ * Debounces saves with 1-second delay.
+ * Only saves if controller.autoSave is enabled.
+ */
function auto_save() {
if (!settings.ctrl().autoSave) {
return;
@@ -46,6 +63,14 @@ function auto_save() {
}, 1000);
}
+/**
+ * Set camera focus on widgets or point.
+ * If point provided, focuses on that point.
+ * Otherwise calculates centroid of selection (or all widgets if sel undefined/empty).
+ * For CAM indexed mode, uses Z=0 focus height. Otherwise uses half of top_z.
+ * @param {Widget|Array} [sel] - Widget(s) to focus on (defaults to all)
+ * @param {object} [point] - Optional point {x, y, z} to focus on directly
+ */
function setFocus(sel, point) {
if (point) {
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));
}
+/**
+ * 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) {
api.conf.save();
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) {
let newset = api.conf.restore(false),
camera = newset.controller.view,
@@ -154,6 +201,10 @@ function restoreWorkspace(ondone, skip_widget_load) {
return toload.length > 0;
}
+/**
+ * Clear all widgets from workspace.
+ * Clears worker cache/memory, selects all widgets, and deletes them.
+ */
function clearWorkspace() {
// free up worker cache/mem
api.client.clear();
@@ -161,6 +212,10 @@ function clearWorkspace() {
platform.delete(selection.meshes());
}
+/**
+ * Check if dark mode is enabled.
+ * @returns {boolean} True if dark mode active
+ */
function is_dark() {
return settings.ctrl().dark;
}
diff --git a/src/kiri/mode/cam/slice.js b/src/kiri/mode/cam/slice.js
index 74675635..6e79d525 100644
--- a/src/kiri/mode/cam/slice.js
+++ b/src/kiri/mode/cam/slice.js
@@ -23,6 +23,7 @@ export async function cam_slice(settings, widget, onupdate, ondone) {
// get tab widgets
let tabW = widget.group.filter(w => w != widget);
+
// merge overlapping tabs
for (let i=0; i