diff --git a/src/kiri/app/api.js b/src/kiri/app/api.js index 2d9c8ccd..d37a80e0 100644 --- a/src/kiri/app/api.js +++ b/src/kiri/app/api.js @@ -10,7 +10,7 @@ import web from '../../moto/webui.js'; import { beta, version } from '../../moto/license.js'; import { broker } from '../../moto/broker.js'; import { client as workers } from './workers.js'; -import { consts, COLOR as color, LISTS as lists } from '../core/consts.js'; +import { consts, LISTS as lists } from '../core/consts.js'; import { device, devices } from './devices.js'; import { functions } from './function.js'; import { group as groupModule } from './groups.js'; @@ -113,7 +113,6 @@ export const api = { .catch(err => console.error('Clipboard Error:', err)); }, clone: Object.clone, - color, conf: settings.conf, const: { LANG, LOCAL, SETUP, SECURE, SPACE, STACKS, diff --git a/src/kiri/app/color/base.js b/src/kiri/app/color/base.js new file mode 100644 index 00000000..7143df03 --- /dev/null +++ b/src/kiri/app/color/base.js @@ -0,0 +1,69 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +/** + * BASE_SCHEME: Shared colors used across all device modes + * All mode-specific schemes inherit from this base + */ +const BASE_SCHEME = { + // Widget colors for selected/deselected states + widget: { + selected: { + light: [ 0xbbff00, 0xbbee00, 0xbbdd00, 0xbb9900 ], + dark: [ 0xbbff00, 0xbbee00, 0xbbdd00, 0xbb9900 ] + }, + deselected: { + light: [ 0xffff00, 0xffdd00, 0xffbb00, 0xff9900 ], + dark: [ 0xffff00, 0xffdd00, 0xffbb00, 0xff9900 ] + }, + disabled: { + // Computed via avgc(0x888888, baseColor, 3) + mixWith: 0x888888, + mixRatio: 3 + } + }, + + // Edge rendering settings + edges: { + color: { + light: 0x888888, + dark: 0x444444 + }, + angle: 20 // Default edge detection angle threshold + }, + + // Wireframe rendering settings + wireframe: { + color: { + light: 0x000000, + dark: 0xaaaaaa + }, + opacity: { + light: 0.5, + dark: 0.25 + } + }, + + // Grid colors for platform + grid: { + major: { + light: 0x999999, + dark: 0x666666 + }, + minor: { + light: 0xcccccc, + dark: 0x333333 + } + }, + + // View-specific opacity defaults (common across modes) + views: { + ARRANGE: { + model_opacity: 1.0 + }, + SLICE: { + slicing_opacity: 0.5 + } + } +}; + +export { BASE_SCHEME }; diff --git a/src/kiri/app/color/cam.js b/src/kiri/app/color/cam.js new file mode 100644 index 00000000..874562d1 --- /dev/null +++ b/src/kiri/app/color/cam.js @@ -0,0 +1,58 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { BASE_SCHEME } from './base.js'; + +/** + * CAM_SCHEME: Colors specific to CAM (3-axis milling/machining) mode + * Inherits from BASE_SCHEME and overrides/extends with CAM-specific values + */ +const CAM_SCHEME = { + // Inherit all base colors + ...BASE_SCHEME, + + // CAM-specific view overrides + views: { + ...BASE_SCHEME.views, + SLICE: { + ...BASE_SCHEME.views.SLICE, + sliced_opacity: 0.2 // CAM shows more during slice (vs FDM 0.0) + }, + PREVIEW: { + preview: { + light: 0xdddddd, + dark: 0x888888 + }, + preview_opacity: { + light: 0.2, + dark: 0.2 + } + } + }, + + // CAM-specific operations + operations: { + // Tab visualization (persistent geometry) + tabs: { + color: { + light: 0x0000dd, // Blue in light mode + dark: 0x00ddff // Cyan in dark mode + }, + opacity: { + light: 0.6, + dark: 0.75 + } + } + }, + + // Gcode preview colors (same as FDM, but documented for CAM) + gcode: { + head: 0x888888, + move: { + light: 0xaaaaaa, + dark: 0x666666 + }, + print: 0x777700 + } +}; + +export { CAM_SCHEME }; diff --git a/src/kiri/app/color/fdm.js b/src/kiri/app/color/fdm.js new file mode 100644 index 00000000..9a7f761d --- /dev/null +++ b/src/kiri/app/color/fdm.js @@ -0,0 +1,42 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { BASE_SCHEME } from './base.js'; + +/** + * FDM_SCHEME: Colors specific to FDM (Fused Deposition Modeling) mode + * Inherits from BASE_SCHEME and overrides/extends with FDM-specific values + */ +const FDM_SCHEME = { + // Inherit all base colors + ...BASE_SCHEME, + + // FDM-specific view overrides + views: { + ...BASE_SCHEME.views, + PREVIEW: { + preview_opacity: 0.0, // FDM hides widget in preview + sliced_opacity: 0.0 + } + }, + + // FDM-specific operations + operations: { + // Support painting overlay + paint: { + overlay: 0x4488ff, // Light blue for painted support regions + opacity: 0.7 + } + }, + + // Gcode preview colors + gcode: { + head: 0x888888, + move: { + light: 0xaaaaaa, + dark: 0x666666 + }, + print: 0x777700 + } +}; + +export { FDM_SCHEME }; diff --git a/src/kiri/app/color/laser.js b/src/kiri/app/color/laser.js new file mode 100644 index 00000000..29143a22 --- /dev/null +++ b/src/kiri/app/color/laser.js @@ -0,0 +1,28 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { BASE_SCHEME } from './base.js'; + +/** + * LASER_SCHEME: Colors for LASER, DRAG (knife), WJET (waterjet), and WEDM (wire-edm) modes + * All 2D device types share similar visual characteristics + * Inherits from BASE_SCHEME with minimal overrides + */ +const LASER_SCHEME = { + // Inherit all base colors + ...BASE_SCHEME, + + // Laser-specific view overrides (similar to CAM) + views: { + ...BASE_SCHEME.views, + PREVIEW: { + preview_opacity: 0.2 + } + } +}; + +// DRAG, WJET, and WEDM all use the same scheme as LASER +const DRAG_SCHEME = LASER_SCHEME; +const WJET_SCHEME = LASER_SCHEME; +const WEDM_SCHEME = LASER_SCHEME; + +export { LASER_SCHEME, DRAG_SCHEME, WJET_SCHEME, WEDM_SCHEME }; diff --git a/src/kiri/app/color/schemes.js b/src/kiri/app/color/schemes.js new file mode 100644 index 00000000..a139a3f2 --- /dev/null +++ b/src/kiri/app/color/schemes.js @@ -0,0 +1,145 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { FDM_SCHEME } from './fdm.js'; +import { CAM_SCHEME } from './cam.js'; +import { SLA_SCHEME } from './sla.js'; +import { LASER_SCHEME, DRAG_SCHEME, WJET_SCHEME, WEDM_SCHEME } from './laser.js'; + +/** + * ColorSchemeRegistry: Central registry for all mode-specific color schemes + * Provides theme-aware color resolution for UI rendering + */ +class ColorSchemeRegistry { + constructor() { + // Map mode IDs to their schemes + this.schemes = { + 1: FDM_SCHEME, // FDM mode + 2: LASER_SCHEME, // LASER mode + 3: CAM_SCHEME, // CAM mode + 4: SLA_SCHEME, // SLA mode + 5: DRAG_SCHEME, // DRAG knife mode + 6: WJET_SCHEME, // Waterjet mode + 7: WEDM_SCHEME // Wire-EDM mode + }; + } + + /** + * Get the complete scheme for a given mode and theme + * @param {number|string} mode - Mode ID (1=FDM, 2=LASER, 3=CAM, etc) or name + * @param {string} theme - 'light' or 'dark' + * @returns {object} Resolved scheme object + */ + getScheme(mode, theme = 'light') { + // Convert mode name to ID if needed + if (typeof mode === 'string') { + mode = this._modeNameToId(mode); + } + + const scheme = this.schemes[mode]; + if (!scheme) { + console.warn(`Unknown mode: ${mode}, using FDM scheme as fallback`); + return this._resolveTheme(this.schemes[1], theme); + } + + return this._resolveTheme(scheme, theme); + } + + /** + * Get a specific color value from a scheme + * @param {number|string} mode - Mode ID or name + * @param {string} theme - 'light' or 'dark' + * @param {string} path - Dot-separated path (e.g., 'widget.selected', 'edges.color') + * @returns {*} Color value or object + */ + getColor(mode, theme, path) { + const scheme = this.getScheme(mode, theme); + return this._getNestedValue(scheme, path); + } + + /** + * Compute disabled color by mixing base color with gray + * Implements: avgc(0x888888, baseColor, 3) + * @param {number} baseColor - Base color hex value + * @returns {number} Mixed color hex value + */ + computeDisabledColor(baseColor) { + // Extract RGB components + const r1 = (0x888888 >> 16) & 0xFF; + const g1 = (0x888888 >> 8) & 0xFF; + const b1 = 0x888888 & 0xFF; + + const r2 = (baseColor >> 16) & 0xFF; + const g2 = (baseColor >> 8) & 0xFF; + const b2 = baseColor & 0xFF; + + // Average with ratio 3:1 (3 parts gray, 1 part base) + const r = Math.floor((r1 * 3 + r2) / 4); + const g = Math.floor((g1 * 3 + g2) / 4); + const b = Math.floor((b1 * 3 + b2) / 4); + + return (r << 16) | (g << 8) | b; + } + + /** + * Resolve theme-specific values throughout the scheme + * @private + */ + _resolveTheme(scheme, theme) { + const resolved = JSON.parse(JSON.stringify(scheme)); // Deep clone + + this._resolveThemeRecursive(resolved, theme); + + return resolved; + } + + /** + * Recursively resolve theme values in an object + * @private + */ + _resolveThemeRecursive(obj, theme) { + for (const key in obj) { + const value = obj[key]; + + if (value && typeof value === 'object') { + // Check if this object has light/dark properties + if ('light' in value && 'dark' in value) { + // Replace the object with the theme-specific value + obj[key] = value[theme]; + } else { + // Recurse into nested objects + this._resolveThemeRecursive(value, theme); + } + } + } + } + + /** + * Get nested value from object using dot-separated path + * @private + */ + _getNestedValue(obj, path) { + return path.split('.').reduce((current, key) => current?.[key], obj); + } + + /** + * Convert mode name to ID + * @private + */ + _modeNameToId(name) { + const modes = { + 'FDM': 1, + 'LASER': 2, + 'CAM': 3, + 'SLA': 4, + 'DRAG': 5, + 'WJET': 6, + 'WEDM': 7 + }; + return modes[name.toUpperCase()] || 1; // Default to FDM + } +} + +// Create singleton instance +const colorSchemeRegistry = new ColorSchemeRegistry(); + +export { colorSchemeRegistry, ColorSchemeRegistry }; diff --git a/src/kiri/app/color/sla.js b/src/kiri/app/color/sla.js new file mode 100644 index 00000000..455ce244 --- /dev/null +++ b/src/kiri/app/color/sla.js @@ -0,0 +1,23 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { BASE_SCHEME } from './base.js'; + +/** + * SLA_SCHEME: Colors specific to SLA (stereolithography/resin) mode + * Inherits from BASE_SCHEME with minimal overrides + */ +const SLA_SCHEME = { + // Inherit all base colors + ...BASE_SCHEME, + + // SLA-specific view overrides (similar to FDM) + views: { + ...BASE_SCHEME.views, + PREVIEW: { + preview_opacity: 0.0, + sliced_opacity: 0.0 + } + } +}; + +export { SLA_SCHEME }; diff --git a/src/kiri/app/conf/manager.js b/src/kiri/app/conf/manager.js index 81fb7339..f1d9ebac 100644 --- a/src/kiri/app/conf/manager.js +++ b/src/kiri/app/conf/manager.js @@ -12,10 +12,20 @@ import { utils } from '../../core/utils.js'; import { version } from '../../../moto/license.js'; import { JSZip } from '../../../ext/jszip-esm.js'; +import { colorSchemeRegistry } from '../color/schemes.js'; + const { areEqual, ls2o, js2o } = utils; -const { COLOR } = consts; const { clone } = Object; +/** + * Get current color scheme + */ +function getColorScheme() { + const mode = api.mode.get_id(); + const theme = api.space.is_dark() ? 'dark' : 'light'; + return colorSchemeRegistry.getScheme(mode, theme); +} + const localFilterKey ='kiri-gcode-filters'; const localFilters = js2o(local.getItem(localFilterKey)) || []; @@ -322,9 +332,10 @@ function updateExtruderFields(device) { onclick() { api.selection.for_widgets(w => { w.anno.extruder = i; + const scheme = getColorScheme(); w.setColor( api.selection.contains(w) ? - COLOR.selected : COLOR.deselected, + scheme.widget.selected : scheme.widget.deselected, undefined, false); }); @@ -332,7 +343,7 @@ function updateExtruderFields(device) { }), h.div({ class: "splat", - style: `background-color: #${COLOR.deselected[i].toString(16)}` + style: `background-color: #${getColorScheme().widget.deselected[i].toString(16)}` }) ]))); } else { diff --git a/src/kiri/app/function.js b/src/kiri/app/function.js index a2f69cc6..e3832c80 100644 --- a/src/kiri/app/function.js +++ b/src/kiri/app/function.js @@ -4,9 +4,19 @@ import { api } from './api.js'; import { client } from './workers.js'; import { codec } from '../core/codec.js'; import { space } from '../../moto/space.js'; -import { COLOR, PMODES } from '../core/consts.js'; +import { PMODES } from '../core/consts.js'; +import { colorSchemeRegistry } from './color/schemes.js'; import { exportFile } from './export.js'; +/** + * Get current color scheme + */ +function getColorScheme() { + const mode = api.mode.get_id(); + const theme = api.space.is_dark() ? 'dark' : 'light'; + return colorSchemeRegistry.getScheme(mode, theme); +} + /** * Tracks completion state of operations to prevent redundant work. * Properties: slice, preview, export @@ -96,7 +106,8 @@ function prepareSlices(callback, scale = 1, offset = 0) { let totalProgress; - api.widgets.setOpacity(COLOR.slicing_opacity); + const scheme = getColorScheme(); + api.widgets.setOpacity(scheme.views.SLICE.slicing_opacity); let segtimes = {}, segNumber = 0, @@ -110,7 +121,8 @@ function prepareSlices(callback, scale = 1, offset = 0) { for (let widget of toSlice) { widget.belt = null; widget.stats.progress = 0; - widget.setColor(COLOR.slicing); + const scheme = getColorScheme(); + widget.setColor(0xffaaaa); // slicing color (from old COLOR.slicing) let { extruder } = widget.anno; if (extruder >= 0) { extruders[extruder] = extruder; @@ -277,9 +289,10 @@ function prepareSlices(callback, scale = 1, offset = 0) { } if (scale === 1) { // clear wireframe - widget.setWireframe(false, COLOR.wireframe, COLOR.wireframe_opacity); - widget.setOpacity(camOrLaser ? COLOR.cam_sliced_opacity : COLOR.sliced_opacity); - widget.setColor(COLOR.deselected); + const scheme = getColorScheme(); + widget.setWireframe(false, scheme.wireframe.color, scheme.wireframe.opacity); + widget.setOpacity(camOrLaser ? scheme.views.SLICE.sliced_opacity ?? 0.2 : scheme.views.PREVIEW?.sliced_opacity ?? 0.0); + widget.setColor(scheme.widget.deselected); api.hide.alert(alert); } } @@ -365,10 +378,12 @@ function preparePreview(callback, scale = 1, offset = 0) { event.emit('preview.begin', pMode); if (isCam) { - api.widgets.setOpacity(isDark ? COLOR.cam_preview_opacity_dark : COLOR.cam_preview_opacity); - api.widgets.setColor(isDark ? COLOR.cam_preview_dark : COLOR.cam_preview); + const scheme = getColorScheme(); + api.widgets.setOpacity(scheme.views.PREVIEW.preview_opacity); + api.widgets.setColor(scheme.views.PREVIEW.preview); } else if (offset === 0) { - api.widgets.setOpacity(COLOR.preview_opacity); + const scheme = getColorScheme(); + api.widgets.setOpacity(scheme.views.PREVIEW.preview_opacity); } let mark = Date.now(), diff --git a/src/kiri/app/platform.js b/src/kiri/app/platform.js index 1fd78121..080f0440 100644 --- a/src/kiri/app/platform.js +++ b/src/kiri/app/platform.js @@ -3,7 +3,8 @@ import { $, h } from '../../moto/webui.js'; import { ajax, js2o } from '../core/utils.js'; import { api } from './api.js'; -import { COLOR, MODES } from '../core/consts.js'; +import { MODES } from '../core/consts.js'; +import { colorSchemeRegistry } from './color/schemes.js'; import { load as file_load } from '../../load/file.js'; import { newBounds } from '../../geo/bounds.js'; import { Packer } from './pack.js'; @@ -13,6 +14,15 @@ import { Widget, newWidget } from './widget.js'; const V0 = new THREE.Vector3(0,0,0); +/** + * Get current color scheme + */ +function getColorScheme() { + const mode = api.mode.get_id(); + const theme = api.space.is_dark() ? 'dark' : 'light'; + return colorSchemeRegistry.getScheme(mode, theme); +} + /** Bounds update callback */ let setbounds = undefined; @@ -154,17 +164,18 @@ function update_size(updateDark = true) { gridMinor = unitMM ? 5 : 25.4 / 10; if (updateDark) { + const scheme = getColorScheme(); if (controller.dark) { space.platform.set({ light: 0.08 }); space.platform.setFont({rulerColor:'#888888'}); - space.platform.setGrid(gridMajor, gridMinor, 0x666666, 0x333333); + space.platform.setGrid(gridMajor, gridMinor, scheme.grid.major, scheme.grid.minor); space.platform.opacity(0.05); space.sky.set({ color: 0, ambient: { intensity: 0.6 } }); document.body.classList.add('dark'); } else { space.platform.set({ light: 0.08 }); space.platform.setFont({rulerColor:'#333333'}); - space.platform.setGrid(gridMajor, gridMinor, 0x999999, 0xcccccc); + space.platform.setGrid(gridMajor, gridMinor, scheme.grid.major, scheme.grid.minor); space.platform.opacity(0.2); space.sky.set({ color: 0xffffff, ambient: { intensity: 1.1 } }); document.body.classList.remove('dark'); @@ -319,16 +330,18 @@ function update_selected() { let b = $(`sel-ext-${i}`); if (b) b.classList.remove('pop-sel'); } + const scheme = getColorScheme(); selection.for_widgets(w => { - w.setColor(COLOR.selected); + w.setColor(scheme.widget.selected); let ext = api.widgets.annotate(w.id).extruder || 0; let b = $(`sel-ext-${ext}`); if (b) b.classList.add('pop-sel'); w.saveState(); }, true); } else { + const scheme = getColorScheme(); selection.for_widgets(w => { - w.setColor(COLOR.selected); + w.setColor(scheme.widget.selected); }, true); } } @@ -374,7 +387,8 @@ function select(widget, shift, recurse = true) { } selection.add(widget); event.emit('widget.select', widget); - widget.setColor(COLOR.selected); + const scheme = getColorScheme(); + widget.setColor(scheme.widget.selected); $(`ws-${widget.id}`)?.classList.add('selected'); selection.update_info(); } @@ -420,7 +434,8 @@ function deselect(widget, recurse = true) { } $(`ws-${widget.id}`)?.classList.remove('selected'); - widget.setColor(COLOR.deselected); + const scheme = getColorScheme(); + widget.setColor(scheme.widget.deselected); platform.update_selected(); selection.update_info(); space.update(); @@ -753,8 +768,9 @@ function changed() { onclick() { let dis = w.meta.disabled = !w.meta.disabled; let sel = api.selection.contains(w); + const scheme = getColorScheme(); api.uc.setClass($(`w-en-${w.id}`), "disabled", dis); - w.setColor(sel ? COLOR.selected : COLOR.deselected, null, false); + w.setColor(sel ? scheme.widget.selected : scheme.widget.deselected, null, false); } }, [ h.i({ class:"fas fa-ban" }) ] diff --git a/src/kiri/app/state/visual.js b/src/kiri/app/state/visual.js new file mode 100644 index 00000000..81807d0e --- /dev/null +++ b/src/kiri/app/state/visual.js @@ -0,0 +1,215 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +/** + * VisualStateManager: Manages 3-layer visual state hierarchy for widgets + * + * Layer hierarchy (bottom to top): + * 1. Base Layer: User persistent preferences (edges, wireframe, opacity) + * 2. Mode Layer: Mode+view specific defaults (FDM/ARRANGE vs CAM/PREVIEW) + * 3. Operation Layer: Temporary context overrides (paint, selection) + * + * Context types: + * - 'paint': OVERRIDE context (FDM support painting, completely replaces material) + * - 'select': TEMPORARY context (selection highlighting) + * - null: Normal state changes following hierarchy + */ +class VisualStateManager { + constructor(widget, scheme, api) { + this.widget = widget; + this.scheme = scheme; + this.api = api; + + // State stack (base → mode → operation) + this.stack = []; + + // Active operation states + this.operations = {}; // { context: state } + } + + /** + * Load user preferences from localStorage (base layer) + */ + loadPreferences() { + const edges = this.api.local.getBoolean('model.edges'); + const wireframe = this.api.local.getBoolean('model.wireframe'); + const wireframeColor = this.api.local.getInt('model.wireframe.color') || 0; + const wireframeOpacity = this.api.local.getFloat('model.wireframe.opacity'); + + this.baseState = { + edges: edges || false, + wireframe: wireframe || false, + wireframeColor: wireframeColor, + wireframeOpacity: wireframeOpacity !== null ? wireframeOpacity : this.scheme.wireframe.opacity, + opacity: 1.0 + }; + + return this.baseState; + } + + /** + * Save user preferences to localStorage + */ + savePreferences() { + if (!this.baseState) return; + + this.api.local.set('model.edges', this.baseState.edges); + this.api.local.set('model.wireframe', this.baseState.wireframe); + if (this.baseState.wireframeColor !== undefined) { + this.api.local.set('model.wireframe.color', this.baseState.wireframeColor); + } + if (this.baseState.wireframeOpacity !== undefined) { + this.api.local.set('model.wireframe.opacity', this.baseState.wireframeOpacity); + } + } + + /** + * Apply mode-specific defaults (mode layer) + * @param {number} mode - Mode ID + * @param {number} viewMode - View mode ID (ARRANGE=1, SLICE=2, PREVIEW=3, ANIMATE=4) + */ + applyModeState(mode, viewMode) { + const viewName = this._viewModeToName(viewMode); + const viewScheme = this.scheme.views[viewName]; + + this.modeState = { + mode, + viewMode, + opacity: viewScheme?.model_opacity ?? 1.0, + previewOpacity: viewScheme?.preview_opacity, + slicingOpacity: viewScheme?.slicing_opacity, + slicedOpacity: viewScheme?.sliced_opacity + }; + + return this.modeState; + } + + /** + * Push a temporary operation state (operation layer) + * @param {string} context - Operation context ('paint', 'select', etc) + * @param {object} state - State to push + * @param {function} state.restoreCallback - Optional callback to restore state on pop + */ + pushState(context, state) { + if (!context) { + console.warn('pushState called without context'); + return; + } + + this.operations[context] = state; + this.stack.push({ layer: 'operation', context, state }); + + return state; + } + + /** + * Pop a temporary operation state + * @param {string} context - Operation context to pop + */ + popState(context) { + if (!context) { + console.warn('popState called without context'); + return; + } + + const operation = this.operations[context]; + if (!operation) { + console.warn(`No operation found for context: ${context}`); + return; + } + + // Call restore callback if provided + if (operation.restoreCallback) { + operation.restoreCallback(); + } + + // Remove from operations and stack + delete this.operations[context]; + this.stack = this.stack.filter(s => s.context !== context); + } + + /** + * Get the current effective state (topmost in hierarchy) + */ + getCurrentState() { + if (this.stack.length > 0) { + return this.stack[this.stack.length - 1].state; + } + if (this.modeState) { + return this.modeState; + } + return this.baseState; + } + + /** + * Check if an operation context is currently active + * @param {string} context - Operation context to check + * @returns {boolean} + */ + isOperationActive(context) { + return context in this.operations; + } + + /** + * Get all active operation contexts + * @returns {Array} + */ + getActiveContexts() { + return Object.keys(this.operations); + } + + /** + * Clear all operation states (e.g., on mode switch) + */ + clearOperations() { + // Call restore callbacks for all active operations + for (const context in this.operations) { + const operation = this.operations[context]; + if (operation.restoreCallback) { + operation.restoreCallback(); + } + } + + this.operations = {}; + this.stack = this.stack.filter(s => s.layer !== 'operation'); + } + + /** + * Get visual state snapshot for undo/redo + */ + getVisualState() { + return { + edges: this.widget.outline ? true : false, + wires: this.widget.wire ? true : false, + opacity: this.widget.getMaterial()?.opacity ?? 1.0 + }; + } + + /** + * Restore visual state from snapshot + * @param {object} state - State snapshot + */ + setVisualState({ edges, wires, opacity }) { + this.widget.cache.vizstate = this.getVisualState(); + this.widget.setEdges(edges ?? false); + this.widget.setWireframe(wires ?? false); + if (opacity !== undefined) { + this.widget.setOpacity(opacity); + } + } + + /** + * Convert view mode ID to name + * @private + */ + _viewModeToName(viewMode) { + const views = { + 1: 'ARRANGE', + 2: 'SLICE', + 3: 'PREVIEW', + 4: 'ANIMATE' + }; + return views[viewMode] || 'ARRANGE'; + } +} + +export { VisualStateManager }; diff --git a/src/kiri/app/visuals.js b/src/kiri/app/visuals.js index 5fe7d357..9334c306 100644 --- a/src/kiri/app/visuals.js +++ b/src/kiri/app/visuals.js @@ -3,12 +3,22 @@ import { api } from './api.js'; import { base } from '../../geo/base.js'; import { consts } from '../core/consts.js'; +import { colorSchemeRegistry } from './color/schemes.js'; import { settings } from './conf/manager.js'; import { space } from '../../moto/space.js'; import STACKS from './stacks.js'; -const { COLOR, VIEWS } = consts; +const { VIEWS } = consts; + +/** + * Get current color scheme + */ +function getColorScheme() { + const mode = api.mode.get_id(); + const theme = api.space.is_dark() ? 'dark' : 'light'; + return colorSchemeRegistry.getScheme(mode, theme); +} /** * Constrain value to min/max bounds. @@ -182,7 +192,8 @@ function hideSlices() { const wireframe = api.local.getBoolean('model.wireframe'); const color = api.local.getInt('model.wireframe.color') || 0; const saved = api.local.getFloat('model.wireframe.opacity'); - const opacity = saved !== null ? saved : COLOR.model_opacity; + const scheme = getColorScheme(); + const opacity = saved !== null ? saved : (scheme.views.ARRANGE?.model_opacity ?? 1.0); if (wireframe) { api.widgets.each(widget => widget.setWireframe(wireframe, color, opacity)); diff --git a/src/kiri/app/widget.js b/src/kiri/app/widget.js index 9aae365f..a6ae0a8a 100644 --- a/src/kiri/app/widget.js +++ b/src/kiri/app/widget.js @@ -3,6 +3,8 @@ import { Widget as WidgetCore } from '../core/widget.js'; import { util } from '../../geo/base.js'; import { avgc } from '../core/utils.js'; +import { colorSchemeRegistry } from './color/schemes.js'; +import { VisualStateManager } from './state/visual.js'; const solid_opacity = 1.0; @@ -14,6 +16,59 @@ function index() { return self.kiri_catalog.index } * Adds rendering, persistence, and visual state management */ class Widget extends WidgetCore { + constructor(id, group) { + super(id, group); + // Visual state manager will be initialized when api is available + this.visualStateManager = null; + } + + /** + * Initialize visual state manager (called when api is available) + * @private + */ + _initVisualStateManager() { + if (this.visualStateManager || !this.api) { + return; + } + + const mode = this.api.mode.get_id(); + const theme = this.api.space.is_dark() ? 'dark' : 'light'; + const scheme = colorSchemeRegistry.getScheme(mode, theme); + + this.visualStateManager = new VisualStateManager(this, scheme, this.api); + this.visualStateManager.loadPreferences(); + } + + /** + * Get current color scheme + * @private + */ + _getScheme() { + if (!this.api) return null; + const mode = this.api.mode.get_id(); + const theme = this.api.space.is_dark() ? 'dark' : 'light'; + return colorSchemeRegistry.getScheme(mode, theme); + } + + /** + * Push a temporary visual state (for operations like paint) + */ + pushVisualState(context, state) { + this._initVisualStateManager(); + if (this.visualStateManager) { + return this.visualStateManager.pushState(context, state); + } + } + + /** + * Pop a temporary visual state + */ + popVisualState(context) { + if (this.visualStateManager) { + this.visualStateManager.popState(context); + } + } + /** * Save widget geometry to catalog */ @@ -144,25 +199,57 @@ class Widget extends WidgetCore { // missing api features in engine mode return; } - let mesh = this.mesh; + const mesh = this.mesh; + + // Handle toggle if (set && set.toggle) { set = this.outline ? false : true; } + + // Remove outline (but keep cache) if (this.outline) { mesh.remove(this.outline); this.outline = null; } - if (set) { - let dark = this.api.space.is_dark(); - let cam = this.api.mode.is_cam(); - let color = dark ? 0x444444 : 0x888888; - let angle = this.api.conf.get().controller.edgeangle || 20; - let edges = new THREE.EdgesGeometry(mesh.geometry, angle); - let material = new THREE.LineBasicMaterial({ color }); - this.outline = new THREE.LineSegments(edges, material); - this.outline.renderOrder = -20; - mesh.add(this.outline); + + if (!set) { + return; // Just hide, keep cache } + + // Resolve current angle and color + const scheme = this._getScheme(); + const angle = scheme?.edges?.angle ?? this.api.conf.get().controller.edgeangle ?? 20; + const color = scheme?.edges?.color ?? 0x888888; + + // Initialize cache if needed + if (!this.cache.edges) { + this.cache.edges = { geometry: null, angle: null, version: 0 }; + } + + const cache = this.cache.edges; + let edgesGeometry; + + // Check if we can reuse cached geometry + if (cache.geometry && + cache.angle === angle && + cache.version === this.geomVersion) { + // Reuse cached geometry + edgesGeometry = cache.geometry; + } else { + // Regenerate edges + edgesGeometry = new THREE.EdgesGeometry(mesh.geometry, angle); + + // Update cache + cache.geometry = edgesGeometry; + cache.angle = angle; + cache.version = this.geomVersion; + } + + // Create outline with cached/new geometry + const material = new THREE.LineBasicMaterial({ color }); + this.outline = new THREE.LineSegments(edgesGeometry, material); + this.outline.renderOrder = -20; + mesh.add(this.outline); } /** @@ -181,14 +268,16 @@ class Widget extends WidgetCore { this.wire = null; } if (set) { - let dark = this.api.space.is_dark(); - let mat = new THREE.MeshBasicMaterial({ + const scheme = this._getScheme(); + const wireColor = scheme?.wireframe?.color ?? 0xaaaaaa; + const wireOpacity = scheme?.wireframe?.opacity ?? 0.5; + const mat = new THREE.MeshBasicMaterial({ wireframe: true, - color: dark ? 0xaaaaaa : 0, - opacity: 0.5, + color: wireColor, + opacity: wireOpacity, transparent: true - }) - let wire = widget.wire = new THREE.Mesh(mesh.geometry.shallowClone(), mat); + }); + const wire = widget.wire = new THREE.Mesh(mesh.geometry.shallowClone(), mat); mesh.add(wire); } if (this.api.view.is_arrange()) { diff --git a/src/kiri/core/consts.js b/src/kiri/core/consts.js index a6491f68..ac3caaff 100644 --- a/src/kiri/core/consts.js +++ b/src/kiri/core/consts.js @@ -1,21 +1,6 @@ /** Copyright Stewart Allen -- All Rights Reserved */ -const COLOR = { - wireframe: 0x444444, - wireframe_opacity: 0.25, - selected: [ 0xbbff00, 0xbbee00, 0xbbdd00, 0xbb9900 ], - deselected: [ 0xffff00, 0xffdd00, 0xffbb00, 0xff9900 ], - model_opacity: 1.0, - preview_opacity: 0.0, - slicing: 0xffaaaa, - slicing_opacity: 0.5, - sliced_opacity: 0.0, - cam_sliced_opacity: 0.2, - cam_preview: 0xdddddd, - cam_preview_opacity: 0.2, - cam_preview_dark: 0x888888, - cam_preview_opacity_dark: 0.2, -}; +// COLOR table has been migrated to src/kiri/app/color/ scheme system const LISTS = { shell: [ @@ -185,7 +170,6 @@ const LAST = 'kiri-last'; const SEED = 'kiri-seed'; export let consts = { - COLOR, LAST, LISTS, MODES, @@ -196,4 +180,4 @@ export let consts = { beltfact: Math.cos(Math.PI / 4) }; -export { COLOR, LAST, LISTS, MODES, VIEWS, PATHS, PMODES, SEED }; +export { LAST, LISTS, MODES, VIEWS, PATHS, PMODES, SEED }; diff --git a/src/kiri/core/widget.js b/src/kiri/core/widget.js index 5d11b2df..26721942 100644 --- a/src/kiri/core/widget.js +++ b/src/kiri/core/widget.js @@ -49,6 +49,8 @@ class Widget { this.boundingBoxNeedsUpdate = true // cache shadow geo this.cache = {}; + // geometry version for edge cache invalidation + this.geomVersion = 0; this.stats = { slice_time: 0, load_time: 0, @@ -193,6 +195,7 @@ class Widget { this.clearShadows(); if (reason !== 'axis') { this.cache.geo = undefined; + this.geomVersion++; // Increment version to invalidate edge cache } if (this.mesh && this.mesh.geometry) { // this fixes ray intersections after the mesh is modified diff --git a/src/kiri/mode/cam/cl-tab.js b/src/kiri/mode/cam/cl-tab.js index eecbc623..ae86f4b2 100644 --- a/src/kiri/mode/cam/cl-tab.js +++ b/src/kiri/mode/cam/cl-tab.js @@ -1,6 +1,7 @@ /** Copyright Stewart Allen -- All Rights Reserved */ import { api } from '../../app/api.js'; +import { colorSchemeRegistry } from '../../app/color/schemes.js'; import { env, isDark } from './init-ui.js'; import { addbox, delbox, clearboxes } from '../../core/boxes.js'; import { traceDone } from './cl-trace.js'; @@ -225,9 +226,15 @@ export function tabClear() { } function boxColor() { - return isDark() ? 0x00ddff : 0x0000dd; + const mode = 3; // CAM mode ID + const theme = isDark() ? 'dark' : 'light'; + const scheme = colorSchemeRegistry.getScheme(mode, theme); + return scheme.operations?.tabs?.color ?? (isDark() ? 0x00ddff : 0x0000dd); } function boxOpacity() { - return isDark() ? 0.75 : 0.6; + const mode = 3; // CAM mode ID + const theme = isDark() ? 'dark' : 'light'; + const scheme = colorSchemeRegistry.getScheme(mode, theme); + return scheme.operations?.tabs?.opacity ?? (isDark() ? 0.75 : 0.6); } diff --git a/src/kiri/mode/fdm/init-ui.js b/src/kiri/mode/fdm/init-ui.js index 0625fc18..b7255bd9 100644 --- a/src/kiri/mode/fdm/init-ui.js +++ b/src/kiri/mode/fdm/init-ui.js @@ -3,6 +3,7 @@ import { newPoint } from '../../../geo/point.js'; import { $ } from '../../../moto/webui.js'; import { api } from '../../app/api.js'; +import { colorSchemeRegistry } from '../../app/color/schemes.js'; import { conf } from '../../app/conf/defaults.js'; import { addPaintOverlayTexture, updatePaintOverlay } from '../../app/paint.js'; @@ -271,14 +272,29 @@ function supportStart({ remove } = { remove: false }) { return [ down.widget.mesh ]; // return api.widgets.meshes(); }; + // Get paint color from scheme + const mode = api.mode.get_id(); + const theme = api.space.is_dark() ? 'dark' : 'light'; + const scheme = colorSchemeRegistry.getScheme(mode, theme); + const paintColor = scheme.operations?.paint?.overlay ?? 0x4488ff; + api.widgets.each(w => { + // Use pushVisualState to track the paint operation + w.pushVisualState('paint', { + material: w.mesh.material[0], + restoreCallback: () => { + w.mesh.material[0] = w.cache.mat; + delete w.cache.mat; + } + }); + let mat = w.cache.mat = w.mesh.material[0]; mat = w.mesh.material[0] = mat.clone(); mat.needsUpdate = true; if (!w.anno.paint) { w.anno.paint = []; } - addPaintOverlayTexture(mat, w.anno.paint, new THREE.Color(0x4488ff)); + addPaintOverlayTexture(mat, w.anno.paint, new THREE.Color(paintColor)); }) api.space.update(); } @@ -321,8 +337,8 @@ function supportDone() { api.feature.on_mouse_drag = undefined; api.hide.alert(alert); api.widgets.each(w => { - w.mesh.material[0] = w.cache.mat; - delete w.cache.mat; + // Use popVisualState to restore original material + w.popVisualState('paint'); }); }