From 7f75981689c736a90b0b9ca9bf0927af2b95b793 Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Sat, 14 Jun 2025 22:10:50 -0400 Subject: [PATCH] start undo/redo --- src2/main/mesh.js | 107 ++++++++++++++++++++++++------------------- src2/mesh/api.js | 87 +++++++++++++++++++++-------------- src2/mesh/build.js | 14 ++++-- src2/mesh/history.js | 92 +++++++++++++++++++++++++++++++++++++ src2/mesh/object.js | 8 ++-- src2/moto/broker.js | 16 +++++-- src2/moto/space.js | 2 +- 7 files changed, 232 insertions(+), 94 deletions(-) create mode 100644 src2/mesh/history.js diff --git a/src2/main/mesh.js b/src2/main/mesh.js index a546a852..5bedb518 100644 --- a/src2/main/mesh.js +++ b/src2/main/mesh.js @@ -7,27 +7,26 @@ import '../add/class.js'; import '../add/three.js'; import '../mesh/build.js'; +import { api } from '../mesh/api.js'; +import { space } from '../moto/space.js'; +import { broker } from '../moto/broker.js'; +import { $, $d, h, estop } from '../moto/webui.js'; import { client as motoClient } from '../moto/client.js'; -import { broker as motoBroker } from '../moto/broker.js'; -import { space as motoSpace } from '../moto/space.js'; -import { open as dataOpen } from '../data/index.js'; -import { api as meshApi } from '../mesh/api.js'; import { split as meshSplit } from '../mesh/split.js'; import { model as meshModel, materials } from '../mesh/model.js'; import { edges as meshEdges } from '../mesh/edges.js'; +import { open as dataOpen } from '../data/index.js'; import { load as fileLoad } from '../load/file.js'; -import { $, $d, h, estop } from '../moto/webui.js'; import { THREE } from '../ext/three.js'; const version = '1.5.7'; -const api = meshApi; -const call = motoBroker.send; +const call = broker.send; const dbindex = [ "admin", "space" ]; const { Quaternion } = THREE; function log() { - return meshApi.log.emit(...arguments); + return api.log.emit(...arguments); } // set below. called once the DOM readyState = complete @@ -38,21 +37,24 @@ function init() { ortho = false, zoomrev = true, zoomspd = 1, - platform = motoSpace.platform, - db = meshApi.db = { + platform = space.platform, + db = api.db = { admin: stores.promise('admin'), space: stores.promise('space') }; + // initialize the API (to avoid circular dependencies) + api.init(); + // mark init time and use count db.admin.put("init", Date.now()); db.admin.get("uses").then(v => db.admin.put("uses", (v||0) + 1)); // setup default workspace - motoSpace.setAntiAlias(true); - motoSpace.useDefaultKeys(false); - motoSpace.init($('container'), delta => { }, ortho); - motoSpace.sky.set({ + space.setAntiAlias(true); + space.useDefaultKeys(false); + space.init($('container'), delta => { }, ortho); + space.sky.set({ grid: false, color: dark ? 0 : 0xffffff }); @@ -71,11 +73,11 @@ function init() { platform.onMove(() => { // save last location and focus db.admin.put('camera', { - place: motoSpace.view.save(), - focus: motoSpace.view.getFocus() + place: space.view.save(), + focus: space.view.getFocus() }); }, 100); - motoSpace.view.setZoom(zoomrev, zoomspd); + space.view.setZoom(zoomrev, zoomspd); // reload stored space when worker is ready motoClient.on('ready', restore_space); @@ -84,7 +86,7 @@ function init() { motoClient.start(`/v2/lib/mesh/work.js?${version}`); // trigger space event binding - call.space_init({ space: motoSpace, platform }); + call.space_init({ space: space, platform }); // trigger ui building call.ui_build(); @@ -92,10 +94,8 @@ function init() { // restore space layout and view from previous session async function restore_space() { - const api = meshApi; - const space = motoSpace; - const db_admin = meshApi.db.admin; - const db_space = meshApi.db.space; + const db_admin = api.db.admin; + const db_space = api.db.space; // let mcache = {}; await db_admin.get("camera") .then(saved => { @@ -180,6 +180,7 @@ async function restore_space() { if (api.prefs.map.info.welcome !== false) { api.welcome(version); } + broker.publish("app_ready"); }); } @@ -187,7 +188,7 @@ async function restore_space() { function space_init(data) { let platcolor = 0x00ff00; let { space, platform } = data; - let { selection } = meshApi; + let { selection } = api; // add file drop handler space.event.addHandlers(self, [ 'drop', (evt) => { @@ -242,9 +243,6 @@ function space_init(data) { return shiftKey ? api.tool.merge() : api.tool.mirror(); case 'KeyU': return shiftKey && api.tool.union(); - case 'KeyA': - if (api.mode.is([ api.modes.edge ])) return meshEdges.add(); - return shiftKey && api.tool.analyze(); case 'KeyE': if (api.mode.is([ api.modes.sketch ])) { estop(evt); @@ -272,8 +270,6 @@ function space_init(data) { return shiftKey ? selection.hide() : space.view.home(); case 'KeyT': return shiftKey ? api.tool.triangulate() : space.view.top(); - case 'KeyZ': - return space.view.reset(); } }, 'keydown', evt => { @@ -293,14 +289,31 @@ function space_init(data) { let rot, floor = api.prefs.map.space.floor !== false; switch (code) { case 'KeyA': + estop(evt); + if (api.mode.is([ api.modes.edge ])) { + return meshEdges.add(); + } if (metaKey || ctrlKey) { if (!selection.sketch()?.selection.all()) { api.mode.object(); selection.set(api.group.list()); } - estop(evt); + } else if (shiftKey) { + api.tool.analyze(); } break; + case 'KeyY': + estop(evt); + if (metaKey || ctrlKey) { + return shiftKey ? api.history.undo() : api.history.redo(); + } + case 'KeyZ': + estop(evt); + if (metaKey || ctrlKey) { + return shiftKey ? api.history.redo() : api.history.undo(); + } else { + return space.view.reset(); + } case 'Escape': if (selection.clear()) { meshEdges.clear(); @@ -480,7 +493,7 @@ function space_init(data) { function load_files(files) { log(`loading file...`); - let api = meshApi; + let api = api; let has_image = false; let has_svg = false; let has_gbr = false; @@ -499,7 +512,7 @@ function load_files(files) { return diam ? poly.offset_open(diam / 2, 'round') : null; }).filter(p => p).flat(); for (let set of [ closed, open, circs, rects ]) { - let group = meshApi.util.uuid(); + let group = api.util.uuid(); for (let poly of set) { sketch.add.polygon({ poly, group }); } @@ -510,11 +523,11 @@ function load_files(files) { if (sketch && has_svg) { fileLoad([...files], { flat: true }) .then(polys => polys.forEach(set => { - let group = meshApi.util.uuid(); + let group = api.util.uuid(); set.forEach(poly => sketch.add.polygon({ poly, group })) })) .catch(error => dbug.error(error)) - .finally(() => meshApi.log.hide()); + .finally(() => api.log.hide()); } else if (has_svg) { api.modal.dialog({ @@ -589,13 +602,13 @@ function load_files_opt(files, opt) { return fileLoad([...files], opt) .then(data => call.space_load(data)) .catch(error => log(error).pin({}) && dbug.error(error)) - // .finally(() => meshApi.log.hide()); + // .finally(() => api.log.hide()); } // add object loader function space_load(data) { if (data && data.length && (data = data.flat()).length) - meshApi.group.new(data.map(el => new meshModel(el))) + api.group.new(data.map(el => new meshModel(el))) .promote() .focus(); } @@ -613,7 +626,7 @@ function key_once_cancel(code) { } function store_meta() { - meshApi.db.admin.put("meta", metaCache); + api.db.admin.put("meta", metaCache); } function update_meta(id, data) { @@ -641,8 +654,8 @@ function object_destroy(id) { // listen for changes like dark mode toggle function set_darkmode(dark) { - let { prefs, model } = meshApi; - let { sky, platform } = motoSpace; + let { prefs, model } = api; + let { sky, platform } = space; prefs.map.space.dark = dark; if (dark) { materials.wireframe.color.set(0xaaaaaa); @@ -667,7 +680,7 @@ function set_darkmode(dark) { colorMinor: 0xeeeeee, }, }); - meshApi.updateFog(); + api.updateFog(); platform.setSize(); for (let m of model.list()) { m.normals({ refresh: true }); @@ -676,13 +689,13 @@ function set_darkmode(dark) { } function set_normals_length(length) { - let { prefs } = meshApi; + let { prefs } = api; prefs.map.normals.length = length || 1; prefs.save(); } function set_normals_color(color) { - let { prefs } = meshApi; + let { prefs } = api; let { map } = prefs; if (map.space.dark) { map.normals.color_dark = color || 0; @@ -693,37 +706,37 @@ function set_normals_color(color) { } function set_surface_radians(radians) { - let { prefs } = meshApi; + let { prefs } = api; prefs.map.surface.radians = parseFloat(radians || 0.1); prefs.save(); } function set_surface_radius(radius) { - let { prefs } = meshApi; + let { prefs } = api; prefs.map.surface.radius = parseFloat(radius || 0.2); prefs.save(); } function set_wireframe_opacity(opacity) { - let { prefs } = meshApi; + let { prefs } = api; prefs.map.wireframe.opacity = parseFloat(opacity || 0.15); prefs.save(); } function set_wireframe_fog(fogx) { - let { prefs } = meshApi; + let { prefs } = api; prefs.map.wireframe.fog = parseFloat(fogx || 3); prefs.save(); } function set_snap_value(snap) { - let { prefs } = meshApi; + let { prefs } = api; prefs.map.space.snap = parseFloat(snap || 1); prefs.save(); } // bind functions to topics -motoBroker.listeners({ +broker.listeners({ key_once, key_once_cancel, load_files, diff --git a/src2/mesh/api.js b/src2/mesh/api.js index c8d9ee90..11ce140a 100644 --- a/src2/mesh/api.js +++ b/src2/mesh/api.js @@ -1,6 +1,7 @@ /** Copyright Stewart Allen -- All Rights Reserved */ import { broker } from '../moto/broker.js'; +import { history } from './history.js'; import { client as motoClient } from '../moto/client.js'; import { space as motoSpace } from '../moto/space.js'; import { util as meshUtil } from './util.js'; @@ -210,14 +211,21 @@ const selection = { }, drag(opt = {}) { + if (opt.start) { + broker.publish('drag_start', selected); + } else if (opt.end) { + broker.publish('drag_end', selected); + } selected.forEach(s => s.drag(opt)); return selection; }, move(dx = 0, dy = 0, dz = 0) { - for (let s of [...selection.groups(), ...selection.sketches()]) { + let set = [...selection.groups(), ...selection.sketches()]; + for (let s of set) { s.move(dx, dy, dz); } + broker.publish('move', { set, dx, dy, dz }); return selection; }, @@ -225,6 +233,7 @@ const selection = { for (let s of selection.models()) { s.move(dx, dy, dz); } + broker.publish('move', { set: selection.models(), dx, dy, dz }); return selection; }, @@ -232,6 +241,7 @@ const selection = { for (let s of selection.groups()) { s.rotate(dx, dy, dz); } + broker.publish('rotate', { set: selection.models(), dx, dy, dz }); return selection; }, @@ -239,6 +249,7 @@ const selection = { for (let s of selection.groups()) { s.qrotate(q); } + broker.publish('qrotate', { set: selection.groups(), q }); return selection; }, @@ -247,6 +258,7 @@ const selection = { let { x, y, z } = s.scale(); s.scale(x * dx, y * dy, z * dz); } + broker.publish('scale', { set: selection.groups(), dx, dy, dz }); return selection; }, @@ -1466,6 +1478,16 @@ let dbug = self.dbug = self.dbug || { // api is augmented in mesh.build (log, modal, download) const api = { + init() { + // publish messages when api functions are called + // broker.wrapObject(selection, 'selection'); + // broker.wrapObject(model, 'model'); + // broker.wrapObject(group, 'group'); + + // optimize db writes by merging updates + prefs.save = meshUtil.deferWrap(prefs.save, 100); + }, + help() { window.open("https://docs.grid.space/projects/mesh-tool"); }, @@ -1496,6 +1518,11 @@ const api = { } }, + history: { + undo() { history.undo() }, + redo() { history.redo() } + }, + // @param object {THREE.Object3D | THREE.Object3D[] | Point} focus(object) { let { center } = object.center ? object : meshUtil.bounds(object); @@ -1567,30 +1594,6 @@ const api = { prefs.save( prefs.map.space.norm = norm ); }, - dbug, - - mode, - - modes, - - selection, - - pattern, - - group, - - model, - - sketch, - - add, - - file, - - tool, - - prefs, - objects() { // return model objects suitable for finding ray intersections return [ @@ -1599,6 +1602,30 @@ const api = { ]; }, + add, + + dbug, + + file, + + group, + + mode, + + modes, + + model, + + pattern, + + prefs, + + selection, + + sketch, + + tool, + isDebug: self.debug === true }; @@ -1608,17 +1635,9 @@ function log() { const call = broker.send; -// publish messages when api functions are called -broker.wrapObject(selection, 'selection'); -broker.wrapObject(model, 'model'); -broker.wrapObject(group, 'group'); - -// optimize db writes by merging updates -// prefs.save = meshUtil.deferWrap(prefs.save, 100); - export { + sketches, selection, groups, - sketches, api }; diff --git a/src2/mesh/build.js b/src2/mesh/build.js index f1545531..2d054c17 100644 --- a/src2/mesh/build.js +++ b/src2/mesh/build.js @@ -11,7 +11,7 @@ import { version } from '../moto/license.js'; const { div, label, input, hr } = h; const { dbug } = api; -const devel = api.isDebug; +const isDebug = api.isDebug; let call = broker.send; let rad = 180 / Math.PI; @@ -135,7 +135,7 @@ const log = api.log = { text: `${dbug.since()} | ${[...arguments].join(' ')}`, time: now }); - if (api.isDebug) { + if (isDebug) { console.log(...data.peek().objs); } while (!log.pinned && data.length && (data.length > lines || now - data[0].time > age)) { @@ -408,6 +408,9 @@ function ui_build() { div({ class: "menu sketch-on" }, [ div('Edit'), div({ class: "menu-items" }, [ + menu_item('Undo', api.history.undo, ['bi-command','Z']), + menu_item('Redo', api.history.redo, ['bi-command','Y']), + hr(), menu_item('Add Circle', api.add.circle), menu_item('Add Rectangle', api.add.rectangle), hr(), @@ -419,13 +422,16 @@ function ui_build() { div({ class: "menu sketch-off" }, [ div('Edit'), div({ class: "menu-items" }, [ + menu_item('Undo', api.history.undo, ['bi-command','Z']), + menu_item('Redo', api.history.redo, ['bi-command','Y']), + hr(), menu_item('Add Cylinder', add.cylinder), menu_item('Add Cube', add.cube), menu_item('Add Gear', add.gear), menu_item('Add Threads', add.threads), hr(), menu_item('Add Sketch', add.sketch), - devel ? menu_item('Add Vertices', add.input) : undefined, + isDebug ? menu_item('Add Vertices', add.input) : undefined, hr(), menu_item('Delete', api.selection.delete, 'bi-backspace'), ]) @@ -773,7 +779,7 @@ function ui_build() { for (let g of api.selection.groups()) { set(g, tempval, value); if (floor && opt.floor !== false) { - g.floor(mesh.group); + g.floor(); } } defer_selection(); diff --git a/src2/mesh/history.js b/src2/mesh/history.js new file mode 100644 index 00000000..eecc3a7d --- /dev/null +++ b/src2/mesh/history.js @@ -0,0 +1,92 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { broker } from "../moto/broker.js"; + +class History { + #pos = -1; + #stack = []; + #tracking = false; + + constructor() { + broker.subscribe({ + + app_ready() { + this.#tracking = true + }, + + drag_start(selected) { + this.push('position', selected.map(model => ({ + model, + pos: model.position().toArray() + }))); + }, + + drag_end(selected) { + this.update(selected.map(model => ({ + model, + pos: model.position().toArray() + }))); + }, + + move(data) { + console.log('move', data); + }, + + rotate(data) { + console.log('rotate', data); + }, + + scale(data) { + console.log('scale', data); + }, + + }, this); + } + + push(type, undo, redo) { + this.#stack.length = ++this.#pos; + this.#stack.push({ + type, + undo, + redo + }); + } + + update(redo, undo) { + let peek = this.#stack.peek(); + if (peek) { + Object.assign(peek, { + redo: redo || peek.redo, + undo: undo || peek.undo + }); + } + } + + do(type, data) { + switch (type) { + case 'position': + for (let { model, pos } of data) { + model.position(...pos); + } + break; + default: + console.warn(`invalid record type: ${type}`); + } + } + + undo() { + if (this.#pos >= 0) { + const rec = this.#stack[this.#pos--]; + this.do(rec.type, rec.undo); + } + } + + redo() { + if (this.#stack.length && this.#pos >= -1 && this.#pos < this.#stack.length - 1) { + const rec = this.#stack[++this.#pos]; + this.do(rec.type, rec.redo); + } + } +} + +export const history = new History(); diff --git a/src2/mesh/object.js b/src2/mesh/object.js index c81bc123..34a901e6 100644 --- a/src2/mesh/object.js +++ b/src2/mesh/object.js @@ -1,10 +1,10 @@ /** Copyright Stewart Allen -- All Rights Reserved */ import { THREE } from '../ext/three.js'; +import { api } from './api.js'; import { broker as motoBroker } from '../moto/broker.js'; import { client as motoClient } from '../moto/client.js'; import { space as motoSpace } from '../moto/space.js'; -import { api as meshApi } from './api.js'; import { util as meshUtil } from './util.js'; const { Matrix4, Vector3, Box3, Box3Helper, Quaternion } = THREE; @@ -28,7 +28,7 @@ class MeshObject { } log() { - // meshApi.log.emit(this.id, this.type, ...arguments); + // api.log.emit(this.id, this.type, ...arguments); } get type() { @@ -49,7 +49,7 @@ class MeshObject { // update worker state worker.object_destroy({id: this.id}); // update object store - meshApi.db.space.remove(this.id); + api.db.space.remove(this.id); // main app cache workspace updates publish.destroy(this.id); } @@ -86,7 +86,7 @@ class MeshObject { } focus() { - meshApi.focus(this.object); + api.focus(this.object); return this; } diff --git a/src2/moto/broker.js b/src2/moto/broker.js index 4ffd81ae..282c09b6 100644 --- a/src2/moto/broker.js +++ b/src2/moto/broker.js @@ -1,10 +1,12 @@ /** Copyright Stewart Allen -- All Rights Reserved */ class Broker { + debug = false; + topics = {}; + used = {}; + send = {}; + constructor() { - this.topics = {}; - this.used = {}; - this.send = {}; // using the pattern "broker.send(msg)" induces runtime errors // when there is not at least one registered listener for a topic // while also allowing for a more natural function call interface @@ -37,7 +39,10 @@ class Broker { return this; } if (typeof topic !== 'string') { - console.trace({invalid_topic: topic}); + for (let [ key, fn ] of Object.entries(topic)) { + this.subscribe(key, fn.bind(listener)); + } + // console.trace({invalid_topic: topic}); return; } let topics = this.topics; @@ -81,6 +86,9 @@ class Broker { if (topic !== ".topic.publish") { this.publish(".topic.publish", {topic, message, options}); } + if (this.debug && !topic.startsWith(".")) { + console.log({ topic, message }); + } // store last seen message on a topic // acts as a tracker for all used topics this.used[topic] = message; diff --git a/src2/moto/space.js b/src2/moto/space.js index b4696be0..7c90ab76 100644 --- a/src2/moto/space.js +++ b/src2/moto/space.js @@ -1407,7 +1407,7 @@ let Space = { const dist = camera.position.distanceTo(viewControl.target); raycaster.params.Line.threshold = Math.min(1, dist / 100); } - broker.publish("space.view.zoom", camera); + // broker.publish("space.view.zoom", camera); updateLastAction(); if (slider) slider(val); });