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