grid-apps-cmms/src/main/mesh.js

1130 lines
39 KiB
JavaScript
Raw Normal View History

2021-12-27 10:14:21 -05:00
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import '../add/array.js';
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 { handles } from '../mesh/handles.js';
import { client as motoClient } from '../moto/client.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 { THREE } from '../ext/three.js';
import { createDocumentManager } from '../mesh/document.js';
const version = '1.5.7';
const call = broker.send;
const dbindex = [ "admin", "documents", "versions" ];
const DOC_META_KEY = '__doc';
2021-12-26 20:01:56 -05:00
const { Quaternion } = THREE;
2024-07-27 17:25:05 -04:00
function log() {
return api.log.emit(...arguments);
2024-07-27 17:25:05 -04:00
}
function boot_status(message = 'loading...') {
const curtain = $('curtain');
if (!curtain) return;
curtain.textContent = String(message || 'loading...');
}
function boot_start(message = 'loading...') {
$('app')?.classList?.add('booting');
boot_status(message);
$d('curtain', 'flex');
}
function boot_done() {
$('app')?.classList?.remove('booting');
$d('curtain', 'none');
}
function get_doc_meta(meta = metaCache) {
if (!meta || typeof meta !== 'object') return {};
return meta[DOC_META_KEY] || {};
}
function capture_camera_state() {
return {
place: space.view.save(),
focus: space.view.getFocus()
};
}
function apply_camera_state(camera) {
if (!camera) return;
if (camera.place) {
space.view.load(camera.place);
}
if (camera.focus) {
space.view.setFocus(camera.focus);
}
}
function save_camera_to_document() {
const dmeta = get_doc_meta(metaCache);
metaCache[DOC_META_KEY] = {
...dmeta,
camera: capture_camera_state()
};
store_meta();
}
let cameraSaveTimer = null;
function schedule_camera_save(delay = 120) {
clearTimeout(cameraSaveTimer);
cameraSaveTimer = setTimeout(() => {
cameraSaveTimer = null;
save_camera_to_document();
}, delay);
}
2022-01-12 15:55:09 -05:00
// set below. called once the DOM readyState = complete
// this is the main() entrypoint called after all dependents load
2021-12-26 13:59:02 -05:00
function init() {
let stores = dataOpen('mesh', { stores: dbindex, version: 5 }).init(),
2021-12-26 13:59:02 -05:00
dark = false,
ortho = false,
zoomrev = true,
2021-12-26 13:59:02 -05:00
zoomspd = 1,
platform = space.platform,
db = api.db = {
admin: stores.promise('admin'),
documents: stores.promise('documents'),
versions: stores.promise('versions')
};
const docman = api.document = createDocumentManager({
admin: db.admin,
documents: db.documents,
versions: db.versions,
maxRevisions: 200
});
db.space = docman.spaceStore;
// initialize the API (to avoid circular dependencies)
api.init();
boot_start('initializing mesh:tool');
// 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));
2021-12-26 13:59:02 -05:00
// setup default workspace
space.setAntiAlias(true);
2022-01-01 22:39:49 -05:00
space.useDefaultKeys(false);
space.init($('container'), delta => { }, ortho);
2021-12-28 13:02:01 -05:00
space.sky.set({
2022-01-07 22:07:49 -05:00
grid: false,
2021-12-28 13:02:01 -05:00
color: dark ? 0 : 0xffffff
});
platform.set({
volume: false,
round: false,
2022-01-15 22:24:54 -05:00
zOffset: 0,
2022-01-07 21:13:31 -05:00
opacity: 0,
color: 0xdddddd,
2021-12-28 13:02:01 -05:00
zoom: { reverse: true, speed: 1 },
size: { width: 2000, depth: 2000, height: 0.05, maxz: 2000 },
grid: { major: 25, minor: 5, zOffset: 0,
colorMajor: 0xcccccc, colorMinor: 0xeeeeee,
colorX: 0xff7777, colorY: 0x7777ff },
2021-12-28 13:02:01 -05:00
});
2022-01-04 09:48:36 -05:00
platform.onMove(() => {
// save camera per-document
save_camera_to_document();
2022-01-04 09:48:36 -05:00
}, 100);
2021-12-26 20:01:56 -05:00
space.view.setZoom(zoomrev, zoomspd);
2021-12-26 19:43:48 -05:00
// trigger ui building
call.ui_build();
// trigger space event binding
call.space_init({ space: space, platform });
// CMMS embedding (not upstream): the host page (public/mesh-edit.php)
// iframes this at /mesh/?cmms_load=...&cmms_save=...&cmms_owner=...
// &cmms_csrf=...&cmms_name=... when launched job-scoped to repair one
// specific attachment, rather than the normal freestanding app.
// Query params, not a window.MESH_TOOL_CONFIG global set by the parent
// frame: this app's own served index.html isn't something the CMMS
// renders, so there's no point to inject a <script> into before this
// module runs, and by the time an iframe's `load` event fires (the
// only parent->child signal available for a same-origin but
// externally-served page) this init() has already run. Query params
// are readable synchronously, before the "hide url params" cleanup
// below strips them - self.MESH_TOOL_CONFIG is still set from them
// here so util.download() (src/mesh/build.js) has one stable place to
// read from regardless of how config arrived.
const cmmsParams = new URLSearchParams(location.search);
const cmmsLoadUrl = cmmsParams.get('cmms_load');
// CMMS light/dark: cmms_dark=1|0 from theme_is_dark() (public/mesh-edit.php).
// Absent for non-CMMS use -> undefined -> boot defaults to dark (below), so
// the freestanding app keeps today's look.
const cmmsDarkParam = cmmsParams.get('cmms_dark');
const cmmsDark = cmmsDarkParam === null ? undefined : cmmsDarkParam === '1';
if (cmmsLoadUrl) {
self.MESH_TOOL_CONFIG = {
loadUrl: cmmsLoadUrl,
saveUrl: cmmsParams.get('cmms_save'),
ownerToken: cmmsParams.get('cmms_owner'),
csrfToken: cmmsParams.get('cmms_csrf'),
originalName: cmmsParams.get('cmms_name'),
dark: cmmsDark,
};
motoClient.on('ready', () => cmms_load_from_url(self.MESH_TOOL_CONFIG));
} else {
self.MESH_TOOL_CONFIG = { dark: cmmsDark };
// reload stored space when worker is ready
motoClient.on('ready', restore_space);
}
// start worker
motoClient.start('../lib/mesh/work.js?' + version);
// hide url params
let wlp = window.location.pathname;
let mio = wlp.indexOf('/mesh/');
if (mio >= 0) {
history.replaceState({}, '', wlp.substring(0,mio + 6));
}
2025-07-07 13:36:03 -04:00
// for electron
self.mesh = { api };
self.electron = navigator.userAgent.includes('Electron');
2022-01-02 16:30:05 -05:00
}
function clear_workspace() {
api.selection.clear();
for (let sk of api.sketch.list().slice()) {
sk.remove();
}
for (let grp of api.group.list().slice()) {
grp.remove();
}
}
async function restore_workspace_from_state(cached = {}, mcache = {}) {
const db_space = api.db.space;
2022-11-15 23:45:45 -05:00
let count = 0;
await Promise.resolve(cached).then(cached => {
2022-10-25 18:23:00 -04:00
const keys = [];
const claimed = [];
for (let [id, data] of Object.entries(cached)) {
2022-10-25 18:23:00 -04:00
keys.push(id);
2022-01-08 22:06:50 -05:00
if (count++ === 0) {
2024-07-27 17:25:05 -04:00
log(`restoring workspace`);
2022-01-08 22:06:50 -05:00
}
if (Array.isArray(data)) {
2022-10-25 18:23:00 -04:00
claimed.push(id);
let models = data
2022-10-25 18:23:00 -04:00
.map(id => {
claimed.push(id);
return { id, md: cached[id] }
})
.filter(r => r.md)
.map(r => new meshModel(r.md, r.id).applyMeta(mcache[r.id]));
2022-01-20 19:21:26 -05:00
if (models.length) {
2024-07-27 17:25:05 -04:00
log(`restored ${models.length} model(s)`);
api.group.new(models, id).applyMeta(mcache[id]);
2022-01-20 19:21:26 -05:00
} else {
2024-07-27 17:25:05 -04:00
log(`removed empty group ${id}`);
2022-11-15 23:45:45 -05:00
db_space.remove(id);
2022-01-20 19:21:26 -05:00
}
2024-07-24 12:29:55 -04:00
} else if (data.type === 'sketch') {
claimed.push(id);
api.add.sketch({ id, ...data });
}
}
2022-10-25 18:23:00 -04:00
for (let id of claimed) {
keys.remove(id);
}
if (keys.length) {
2024-07-27 17:25:05 -04:00
log(`removing ${keys.length} unclaimed meshes`);
2022-10-25 18:23:00 -04:00
}
for (let id of keys) {
2022-11-15 23:45:45 -05:00
db_space.remove(id);
2022-10-25 18:23:00 -04:00
delete mcache[id];
}
2024-08-02 10:17:40 -04:00
metaCache = mcache;
api.document.setMeta(metaCache);
}).then(() => {
2022-01-18 16:34:42 -05:00
return api.prefs.load().then(() => {
let { map } = api.prefs;
2022-01-13 17:01:35 -05:00
let { space, mode } = map;
api.grid(space.grid);
2022-01-14 16:45:40 -05:00
let selist = space.select || [];
let smodel = api.model.list().filter(m => selist.contains(m.id));
let sgroup = api.group.list().filter(m => selist.contains(m.id));
let tolist = space.tools || [];
let tmodel = api.model.list().filter(m => tolist.contains(m.id));
let tgroup = api.group.list().filter(m => tolist.contains(m.id));
let sklist = api.sketch.list().filter(s => selist.contains(s.id));
api.selection.set([...smodel, ...sgroup, ...sklist], [...tmodel, ...tgroup]);
api.mode[mode]();
let bootDark = (self.MESH_TOOL_CONFIG || {}).dark;
set_darkmode(bootDark === undefined ? true : bootDark);
});
});
}
// restore space layout and view from previous session
async function restore_space() {
const db_admin = api.db.admin;
const db_space = api.db.space;
const docman = api.document;
boot_status('loading document');
const currentDoc = await docman.restoreOrCreate();
const mcache = docman.getMeta() || {};
const oldCamera = await db_admin.get("camera");
const docCamera = get_doc_meta(mcache).camera || oldCamera || null;
boot_status('restoring workspace');
const cached = await db_space.iterate({ map: true }) || {};
docman.pause();
try {
await restore_workspace_from_state(cached, mcache);
} finally {
docman.resume();
}
boot_status('restoring view');
apply_camera_state(docCamera);
space.update();
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
boot_status('finalizing');
Promise.resolve().finally(() => {
2022-01-18 16:34:42 -05:00
// hide loading curtain
boot_done();
// restore handles visibility
handles.setEnabled(api.prefs.map.space.bounds ?? false);
// restore script if was showing
if (api.prefs.map.space.script) {
api.script.show();
}
2022-02-25 15:09:05 -05:00
if (api.prefs.map.info.welcome !== false) {
api.welcome(version);
}
api.file.set_doc_name(currentDoc?.name || 'Untitled');
broker.publish("app_ready");
});
}
async function document_new(opt = {}) {
const docman = api.document;
await docman.flush();
await docman.commit('document.autosave', 'document.autosave');
docman.pause();
try {
clear_workspace();
space.view.home();
metaCache = {
[DOC_META_KEY]: {
camera: capture_camera_state()
}
};
await docman.create(opt.name || 'Untitled');
docman.setMeta(metaCache);
} finally {
docman.resume();
}
await docman.commit('document.new', 'document.new');
api.file.set_doc_name(docman.current?.name || 'Untitled');
}
async function document_open(opt = {}) {
const docman = api.document;
const id = String(opt?.id || '');
if (!id) return;
await docman.flush();
await docman.open(id, { autosave: opt.autosave !== false });
const cached = docman.getSpace() || {};
const mcache = docman.getMeta() || {};
docman.pause();
try {
clear_workspace();
await restore_workspace_from_state(cached, mcache);
} finally {
docman.resume();
}
apply_camera_state(get_doc_meta(mcache).camera);
api.file.set_doc_name(docman.current?.name || 'Untitled');
}
// add space event bindings
2022-01-02 16:30:05 -05:00
function space_init(data) {
let platcolor = 0x00ff00;
let { space, platform } = data;
let { selection } = api;
function selection_or_visible_entities() {
const selected = api.selection.list(true);
if (selected?.length) return selected;
return [
...api.group.list().filter(g => g.visible()),
...api.sketch.list().filter(s => s.visible())
];
}
function fit_visible() {
const entities = selection_or_visible_entities();
const objects = entities.map(e => e?.object).filter(o => o);
return space.view.fit(undefined, {
padding: 1,
visibleOnly: true,
objects: objects.length ? objects : undefined
});
}
function focus_visible() {
const entities = selection_or_visible_entities();
if (entities.length) {
return api.focus(entities);
}
return api.focus([
...api.group.list(),
...api.sketch.list()
]);
}
function norm_code(evt) {
if (evt?.code) return evt.code;
const key = evt?.key;
if (!key) return '';
if (key === ' ') return 'Space';
if (key === 'Spacebar') return 'Space';
if (key === 'Escape') return 'Escape';
if (key.length === 1) {
const up = key.toUpperCase();
if (up >= 'A' && up <= 'Z') return `Key${up}`;
if (up >= '0' && up <= '9') return `Digit${up}`;
}
return key;
}
// add file drop handler
space.event.addHandlers(self, [
'drop', (evt) => {
estop(evt);
platform.set({ opacity: 0, color: platcolor });
2022-01-06 17:28:57 -05:00
call.load_files([...evt.dataTransfer.files]);
},
'dragover', evt => {
estop(evt);
evt.dataTransfer.dropEffect = 'copy';
let color = platform.setColor(0x00ff00);
if (color !== 0x00ff00) platcolor = color;
platform.set({ opacity: 0.1 });
},
'dragleave', evt => {
platform.set({ opacity: 0, color: platcolor });
2021-12-31 14:12:42 -05:00
},
// camera interactions (orbit/pan/dolly) are not guaranteed to trigger platform.onMove
'wheel', () => schedule_camera_save(),
'mouseup', () => schedule_camera_save(),
'touchend', () => schedule_camera_save(),
2021-12-31 14:12:42 -05:00
'keypress', evt => {
2022-01-06 15:10:03 -05:00
if (api.modal.showing) {
return;
}
2022-01-18 16:34:42 -05:00
if (evt.key === '?') {
2022-02-25 15:09:05 -05:00
return api.welcome(version);
2022-01-18 16:34:42 -05:00
}
let { shiftKey, metaKey, ctrlKey, target } = evt;
let code = norm_code(evt);
2025-10-24 23:56:06 -04:00
if (target.nodeName === 'TEXTAREA') {
api.script.changed();
return;
}
switch (code) {
case 'Digit1':
return api.mode.sketch();
case 'Digit2':
return api.mode.object();
case 'Digit3':
return api.mode.tool();
case 'Digit4':
return api.mode.surface();
case 'Digit5':
return api.mode.face();
case 'Digit6':
return api.mode.edge();
case 'KeyB':
return selection.boundsBox({toggle:true});
2022-01-08 21:00:19 -05:00
case 'KeyC':
if (shiftKey) {
return selection.floor();
} else {
return selection.centerXY().focus();
}
case 'KeyD':
return shiftKey && api.tool.duplicate();
2022-01-14 14:14:13 -05:00
case 'KeyE':
2024-07-25 00:05:24 -04:00
if (api.mode.is([ api.modes.sketch ])) {
estop(evt);
2024-07-25 00:05:24 -04:00
return api.sketch.extrude();
}
return;
2022-01-07 23:30:19 -05:00
case 'KeyG':
return shiftKey ?
(api.mode.is([ api.modes.sketch ]) ? api.sketch.arrange.group() : api.tool.regroup()) :
api.grid();
case 'KeyH':
if (shiftKey) return selection.hide();
schedule_camera_save(180);
return space.view.home();
case 'KeyI':
return api.file.import();
2022-01-08 22:06:50 -05:00
case 'KeyL':
2022-03-31 20:45:49 -04:00
return api.log.toggle({ spinner: false });
case 'KeyM':
return shiftKey ? api.tool.merge() : api.tool.mirror();
case 'KeyN':
return shiftKey ? api.tool.invert() : api.normals();
case 'KeyQ':
return api.settings();
case 'KeyR':
return estop(evt, api.tool.rename());
2022-01-12 00:28:33 -05:00
case 'KeyS':
if (!api.mode.is([ api.modes.object ])) return;
return shiftKey ? selection.visible({toggle:true}) : meshSplit.start();
case 'KeyT':
if (shiftKey) return api.tool.triangulate();
schedule_camera_save(180);
return space.view.top();
case 'KeyU':
return shiftKey && api.tool.union();
case 'KeyV':
return shiftKey ? selection.show() : selection.focus();
case 'KeyW':
return shiftKey ? api.wireframe() : api.wireedges();
case 'KeyX':
return api.file.export();
2021-12-31 16:59:38 -05:00
}
2021-12-31 14:12:42 -05:00
},
'keydown', evt => {
let { shiftKey, metaKey, ctrlKey, target } = evt;
let code = norm_code(evt);
const key = evt?.key;
2025-10-24 23:56:06 -04:00
if (target.nodeName === 'TEXTAREA') {
if (code === 'Tab') {
estop(evt);
const start = target.selectionStart;
const end = target.selectionEnd;
const val = target.value;
const upto = val.slice(0, start);
const col = start - upto.lastIndexOf('\n') - 1;
const tab = ' '.substring(col % 4);
target.value = val.slice(0, start) + tab + val.slice(end);
target.selectionStart = target.selectionEnd = start + tab.length;
}
if (code === 'Enter' && metaKey) {
api.script.execute();
} else {
api.script.changed();
}
return;
}
let once = keyOnce[code];
if (once) {
delete keyOnce[code];
return once(evt);
}
2022-01-06 15:10:03 -05:00
if (api.modal.showing) {
if (code === 'Escape') {
2022-01-20 15:42:28 -05:00
api.modal.cancel();
2022-01-06 15:10:03 -05:00
}
return;
}
const isFit = code === 'KeyF' ||
key === 'f' ||
key === 'F';
if (isFit && !(metaKey || ctrlKey)) {
estop(evt);
const rv = shiftKey ? focus_visible() : fit_visible();
schedule_camera_save(220);
return rv;
}
const isSpace = code === 'Space' ||
code === 'Spacebar' ||
key === ' ' ||
key === 'Spacebar';
if (isSpace) {
if (selection.clear()) {
meshEdges.clear();
meshSplit.end();
}
estop(evt);
return;
}
let rv = (Math.PI / 12);
let rot;
let floor = api.prefs.map.space.floor !== false;
2022-01-01 17:28:47 -05:00
switch (code) {
2022-01-01 15:28:59 -05:00
case 'KeyA':
estop(evt);
if (api.mode.is([ api.modes.edge ])) {
return meshEdges.add();
}
2022-01-01 17:28:47 -05:00
if (metaKey || ctrlKey) {
if (!selection.sketch()?.selection.all()) {
api.mode.object();
selection.set(api.group.list());
}
} else if (shiftKey) {
api.tool.analyze();
2022-01-01 15:28:59 -05:00
}
2022-01-01 17:28:47 -05:00
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 {
space.view.reset();
schedule_camera_save(220);
return;
}
2022-01-01 15:28:59 -05:00
case 'Escape':
if (selection.clear()) {
meshEdges.clear();
meshSplit.end();
}
2022-01-01 15:28:59 -05:00
estop(evt);
break;
case 'Backspace':
case 'Delete':
let mode = api.mode.get();
2024-07-24 19:30:44 -04:00
if ([api.modes.object, api.modes.tool, api.modes.sketch].contains(mode)) {
selection.delete();
} else {
2024-09-04 23:11:34 -04:00
for (let m of api.model.list()) {
m.deleteSelections();
space.refresh()
}
}
2022-01-01 15:28:59 -05:00
estop(evt);
break;
2021-12-31 14:12:42 -05:00
case 'ArrowUp':
rot = selection.rotate(-rv,0,0);
2021-12-31 14:12:42 -05:00
break;
case 'ArrowDown':
rot = selection.rotate(rv,0,0);
2021-12-31 14:12:42 -05:00
break;
case 'ArrowLeft':
2022-01-01 17:28:47 -05:00
if (shiftKey) {
rot = selection.rotate(0,-rv,0);
2022-01-01 17:28:47 -05:00
} else {
floor = false;
rot = selection.rotate(0,0,rv);
2022-01-01 17:28:47 -05:00
}
2021-12-31 14:12:42 -05:00
break;
case 'ArrowRight':
2022-01-01 17:28:47 -05:00
if (shiftKey) {
rot = selection.rotate(0,rv,0);
2022-01-01 17:28:47 -05:00
} else {
floor = false;
rot = selection.rotate(0,0,-rv);
2022-01-01 17:28:47 -05:00
}
2021-12-31 14:12:42 -05:00
break;
}
if (rot && floor) {
2024-08-09 21:53:33 -04:00
rot.floor();
}
}
]);
2022-01-12 23:02:08 -05:00
// mouse hover/click handlers. required to enable model drag in space.js
2024-07-25 14:05:44 -04:00
// called two ways:
// without args to return a list of selectable targets
// with args when a selection is made
space.mouse.downSelect((int, event, ints) => {
2024-07-26 16:28:52 -04:00
let obj = int?.object;
if (obj) api.selection.drag({ start: int.object });
if (obj) obj.sketch_item?.sketch.drag({ start: int.object });
return selection.count() && !event.altKey ? api.objects(true) : undefined;
});
2024-07-25 14:05:44 -04:00
// called two ways:
// without args to return a list of selectable targets
// with args when a selection is made
space.mouse.upSelect((int, event, ints) => {
if (ints?.length > 1) {
// ensure sketch items are returned before sketch plane / handles
2024-07-25 14:05:44 -04:00
let els = ints.filter(i => i.object.sketch_item);
int = els.length ? els[0] : int;
}
2024-07-25 14:05:44 -04:00
if (event?.target?.nodeName === "CANVAS") {
// a selection was made
const { model, sketch, sketch_item } = int?.object || {};
const { altKey, ctrlKey, metaKey, shiftKey } = event;
if (meshSplit.active()) {
return meshSplit.select(model);
}
if (model) {
const group = model.group;
2022-01-13 17:01:35 -05:00
if (metaKey) {
// set focus on intersected face
const { x, y, z } = int.point;
const q = new Quaternion().setFromRotationMatrix(group.object.matrix);
// rotate normal using group's matrix
const normal = shiftKey ? int.face.normal.applyQuaternion(q) : undefined;
2022-01-12 23:02:08 -05:00
// y,z swap due to world rotation for orbit controls
api.focus({ center: { x, y:-z, z:y }, normal });
2024-07-27 20:01:13 -04:00
let one = api.sketch.selected.one;
2024-07-27 00:51:31 -04:00
if (one && confirm('attach sketch to face?')) {
one.center = {
x, y: -z, z: y
};
one.normal = {
x: normal.x,
y: normal.y,
z: normal.z
};
one.render();
}
} else if (ctrlKey && selection.contains(model)) {
2022-01-12 23:02:08 -05:00
// rotate selected face towawrd z "floor"
2024-08-09 21:53:33 -04:00
group.rotateTowardZ(int.face.normal);
selection.update();
} else {
const { modes } = api;
const { surface } = api.prefs.map;
const opt = { radians: 0, radius: surface.radius };
const mode = api.mode.get();
switch(mode) {
2024-07-24 16:57:42 -04:00
case modes.sketch:
api.mode.object();
2022-01-13 17:01:35 -05:00
case modes.object:
case modes.tool:
selection.toggle(shiftKey ? model : model.group, mode === modes.tool);
2022-01-13 17:01:35 -05:00
break;
case modes.surface:
opt.radians = surface.radians;
2022-01-13 17:01:35 -05:00
case modes.face:
// find faces adjacent to point/line clicked
model.find(int,
altKey ? { toggle: true } :
shiftKey ? { clear: true } : { select: true },
opt);
2022-01-13 17:01:35 -05:00
break;
case modes.edge:
meshEdges.select();
break;
2022-01-13 17:01:35 -05:00
}
}
} else if (sketch) {
if (metaKey) {
// set focus on intersected face
const { x, y, z } = int.point;
// rotate normal using group's matrix
const normal = shiftKey ? int.face.normal.clone(): undefined;
// ransform the normal to align with sketch group
normal?.transformDirection(int.object.sketch.group.matrix);
// y,z swap due to world rotation for orbit controls
api.focus({ center: { x, y:-z, z:y }, normal });
} else {
selection.toggle(sketch);
}
2024-07-25 14:05:44 -04:00
} else if (sketch_item) {
sketch_item.toggle();
} else {
// console.log('unknown selection');
}
} else {
2024-07-25 14:05:44 -04:00
// return objects upSelect can choose from
let visible = api.objects().filter(o => o.visible);
let sketches = api.selection.sketches();
if (sketches.length) {
2024-07-25 14:05:44 -04:00
// when sketch selected, append sketch items, too
visible.appendAll(sketches[0].object.children);
};
2024-07-25 14:05:44 -04:00
return visible;
}
});
space.mouse.onDrag((delta, offset, up = false) => {
2024-07-25 14:05:44 -04:00
let { mode, modes } = api;
if (delta) {
if (selection.count()) {
2024-07-26 16:28:52 -04:00
selection.drag({ delta, offset });
2024-07-24 19:30:44 -04:00
}
} else if (up) {
if (selection.count()) {
selection.drag({ end: true });
}
} else if (mode.is([ modes.object, modes.tool, modes.sketch ])) {
2024-07-25 14:05:44 -04:00
// return true if there are draggable elements
return api.objects().length > 0 || api.selection.sketches().length;
}
});
2022-01-02 16:30:05 -05:00
}
// CMMS embedding (not upstream): fetch bytes from the host-provided,
// already-validated loadUrl and feed them through the exact same
// load_files() path drag-and-drop/the Import button already use - a
// fetched Blob wrapped in a File object looks identical to one the user
// picked, so no new parsing/import logic is needed here at all.
async function cmms_load_from_url(config) {
// restore_space() (the normal boot path) is intentionally skipped for a
// CMMS-scoped launch (this loads one specific file, not whatever was
// last open in this browser - see init()'s own comment above) - but its
// finalization tail (hide the loading curtain, enable handles, publish
// app_ready) only ever runs inside restore_space() itself, so skipping
// it meant the curtain never went away even after the file loaded fine
// underneath it (CMMS bug report, 2026-08-27: froze on the loading
// curtain despite attachment.php's fetch succeeding). app_ready
// subscribers never fired either - mesh/history.js's undo-tracking
// enable, mesh/build.js's doc-name label. try/finally so this always
// happens, even if the fetch or file itself fails - a file that can't
// load shouldn't leave the user stuck behind the curtain either.
//
// document_new() (not restoreOrCreate()) is what actually creates a
// genuinely fresh document with the real filename - restoreOrCreate()
// revives whatever document this browser last had open (right back into
// the "not whatever was last open in this browser" problem the comment
// above is warning about), which is also why this first showed up
// titled "Untitled" instead of the real filename (2026-08-27 follow-up
// report): it only read as fixed on a fresh/empty IndexedDB, and would
// have shown a stale name on any browser with mesh history already in it.
try {
const response = await fetch(config.loadUrl);
if (!response.ok) {
log(`CMMS load failed: HTTP ${response.status}`).pin({});
return;
}
const buf = await response.arrayBuffer();
const name = config.originalName || 'attachment.stl';
const file = new File([buf], name, { type: 'application/octet-stream' });
await document_new({ name });
load_files([file]);
} finally {
boot_done();
handles.setEnabled(api.prefs.map.space.bounds ?? false);
broker.publish("app_ready");
}
}
2022-01-06 17:28:57 -05:00
function load_files(files) {
2024-07-27 17:25:05 -04:00
log(`loading file...`);
let has_image = false;
let has_svg = false;
2024-07-27 16:37:42 -04:00
let has_gbr = false;
2024-07-27 20:01:13 -04:00
let sketch = api.sketch.selected.one;
for (let file of files) {
has_image = has_image || file.type === 'image/png';
2024-07-27 17:25:05 -04:00
has_svg = has_svg || file.name.toLowerCase().endsWith(".svg") > 0;
has_gbr = has_gbr || file.name.toLowerCase().endsWith(".gbr") > 0;
}
2024-07-27 16:37:42 -04:00
if (sketch && has_gbr) {
fileLoad([...files], { flat: true }).then(layers => {
2024-07-27 16:37:42 -04:00
for (let layer of layers.flat()) {
let { circs, closed, open, rects } = layer;
open = open.map(poly => {
const diam = poly.tool?.shape?.diameter;
2024-07-27 17:55:34 -04:00
return diam ? poly.offset_open(diam / 2, 'round') : null;
}).filter(p => p).flat();
for (let set of [ closed, open, circs, rects ]) {
let group = api.util.uuid();
for (let poly of set) {
sketch.add.polygon({ poly, group });
}
2024-07-27 16:37:42 -04:00
}
}
});
} else
if (sketch && has_svg) {
fileLoad([...files], { flat: true })
2024-07-27 16:37:42 -04:00
.then(polys => polys.forEach(set => {
let group = api.util.uuid();
set.forEach(poly => sketch.add.polygon({ poly, group }))
2024-07-27 16:37:42 -04:00
}))
.catch(error => dbug.error(error))
.finally(() => api.log.hide());
2024-07-27 16:37:42 -04:00
} else
if (has_svg) {
api.modal.dialog({
title: `svg import`,
body: [ h.div({ class: "image-import" }, [
h.div([
h.label("extrude"),
h.input({ id: "extrude_height", value: 5, size: 4 })
]),
h.div([
h.label("repair"),
h.input({ id: "svg_repair", type: "checkbox", checked: true })
]),
h.div([
h.button({ _: "import", onclick() {
let { svg_repair, extrude_height } = api.modal.bound;
load_files_opt(files, {
soup: svg_repair.checked,
2025-05-19 15:57:24 -04:00
depth: parseFloat(extrude_height.value)
});
api.modal.hide();
} }),
])
]) ]
});
2024-07-27 16:37:42 -04:00
} else
if (has_image) {
api.modal.dialog({
title: `image import`,
body: [ h.div({ class: "image-import" }, [
h.div([
h.label("invert pixels"),
h.input({ id: "inv_image", type: "checkbox" })
]),
h.div([
h.label("invert alpha"),
h.input({ id: "inv_alpha", type: "checkbox" })
]),
h.div([
h.label("border size"),
2022-05-11 23:20:36 -04:00
h.input({ id: "img_border", value: 0, size: 4 })
]),
h.div([
h.label("blur pixels"),
2022-05-11 23:20:36 -04:00
h.input({ id: "img_blur", value: 0, size: 4 })
]),
h.div([
h.label("base pixels"),
2022-05-11 23:20:36 -04:00
h.input({ id: "img_base", value: 0, size: 4 })
]),
h.div([
h.button({ _: "import", onclick() {
let { inv_image, inv_alpha, img_border, img_blur, img_base } = api.modal.bound;
load_files_opt(files, {
inv_image: inv_image.checked,
inv_alpha: inv_alpha.checked,
border: parseInt(img_border.value || 0),
blur: parseInt(img_blur.value || 0),
base: parseInt(img_base.value || 0),
});
api.modal.hide();
} }),
])
]) ]
});
} else {
2022-05-13 15:05:21 -04:00
load_files_opt(files);
}
}
function load_files_opt(files, opt) {
return fileLoad([...files], opt)
.then(data => call.space_load(data))
2024-07-27 17:25:05 -04:00
.catch(error => log(error).pin({}) && dbug.error(error))
// .finally(() => api.log.hide());
2022-01-06 17:28:57 -05:00
}
// add object loader
2022-01-02 16:30:05 -05:00
function space_load(data) {
if (data && data.length && (data = data.flat()).length)
api.group.new(data.map(el => new meshModel(el)))
.promote()
2021-12-31 16:59:38 -05:00
.focus();
2022-01-02 16:30:05 -05:00
}
2021-12-26 13:59:02 -05:00
2024-08-02 10:17:40 -04:00
let metaCache = {};
let keyOnce = {};
function key_once(data) {
const { code, fn } = data;
keyOnce[code] = fn;
}
function key_once_cancel(code) {
delete keyOnce[code];
}
2024-08-02 10:17:40 -04:00
function store_meta() {
api.document?.setMeta?.(metaCache);
2024-08-02 10:17:40 -04:00
}
function update_meta(id, data) {
let meta = metaCache[id] = metaCache[id] || {};
Object.assign(meta, data);
store_meta();
}
// cache model visibility for page restores
function object_visible(data) {
let { id, visible } = data;
update_meta(id, { visible });
}
// cache model matrices for page restores
2024-08-09 21:53:33 -04:00
function object_meta(data) {
let { id, meta } = data;
update_meta(id, meta);
}
function object_destroy(id) {
2024-08-02 10:17:40 -04:00
delete metaCache[id];
store_meta();
}
2022-01-18 16:34:42 -05:00
// listen for changes like dark mode toggle
// CMMS patch: upstream 913977ec hard-wired this to dark (deleting the light
// branch). Restored from 913977ec^ so the Mesh:Tool follows the CMMS light/dark
// theme (boot arg from MESH_TOOL_CONFIG.dark, default dark for non-CMMS use).
2022-01-18 16:34:42 -05:00
function set_darkmode(dark) {
let { prefs, model } = api;
let { sky, platform } = space;
prefs.map.space.dark = dark;
if (dark) {
materials.wireframe.color.set(0xaaaaaa);
materials.wireline.color.set(0xaaaaaa);
$('app')?.classList.add('dark');
} else {
materials.wireframe.color.set(0, 0, 0);
materials.wireline.color.set(0, 0, 0);
$('app')?.classList.remove('dark');
}
sky.set({
color: dark ? 0 : 0xffffff,
ambient: { intensity: dark ? 0.55 : 1.1 }
});
2022-01-18 16:34:42 -05:00
platform.set({
light: 0.08,
grid: dark ? {
2022-01-18 16:34:42 -05:00
colorMajor: 0x666666,
colorMinor: 0x333333,
} : {
colorMajor: 0xcccccc,
colorMinor: 0xeeeeee,
2022-01-18 16:34:42 -05:00
},
});
api.updateFog();
2022-01-19 20:42:22 -05:00
platform.setSize();
2022-01-18 20:58:46 -05:00
for (let m of model.list()) {
m.normals({ refresh: true });
}
2022-01-18 16:34:42 -05:00
prefs.save();
}
2022-02-25 13:56:09 -05:00
function set_normals_length(length) {
let { prefs, model } = api;
2022-02-25 13:56:09 -05:00
prefs.map.normals.length = length || 1;
prefs.save();
// Update existing normals
for (let m of model.list()) {
if (m.normals()) {
m.normals({ refresh: true });
}
}
2022-02-25 13:56:09 -05:00
}
function set_normals_color(color) {
let { prefs, model } = api;
2022-02-25 13:56:09 -05:00
let { map } = prefs;
map.normals.color_dark = color || 0;
map.normals.color_lite = color || 0;
2022-02-25 13:56:09 -05:00
prefs.save();
// Update existing normals
for (let m of model.list()) {
if (m.normals()) {
m.normals({ refresh: true });
}
}
2022-02-25 13:56:09 -05:00
}
function set_surface_radians(radians) {
let { prefs } = api;
prefs.map.surface.radians = parseFloat(radians || 0.1);
prefs.save();
}
function set_surface_radius(radius) {
let { prefs } = api;
prefs.map.surface.radius = parseFloat(radius || 0.2);
prefs.save();
}
2022-04-18 15:49:52 -04:00
function set_wireframe_opacity(opacity) {
let { prefs, model } = api;
2022-04-18 15:49:52 -04:00
prefs.map.wireframe.opacity = parseFloat(opacity || 0.15);
prefs.save();
// Update existing wireframes
for (let m of model.list()) {
if (m.wireframe().enabled) {
m.opacity({temp: prefs.map.wireframe.opacity});
}
}
2022-04-18 15:49:52 -04:00
}
function set_wireframe_fog(fogx) {
let { prefs } = api;
2022-04-18 15:49:52 -04:00
prefs.map.wireframe.fog = parseFloat(fogx || 3);
prefs.save();
api.updateFog();
2022-04-18 15:49:52 -04:00
}
function set_snap_value(snap) {
let { prefs } = api;
prefs.map.space.snap = parseFloat(snap || 1);
prefs.save();
}
2022-01-02 16:30:05 -05:00
// bind functions to topics
broker.listeners({
key_once,
key_once_cancel,
2022-01-06 17:28:57 -05:00
load_files,
2024-08-09 21:53:33 -04:00
object_meta,
object_destroy,
2024-08-02 10:17:40 -04:00
object_visible,
2022-02-25 13:56:09 -05:00
space_init,
space_load,
2022-01-18 16:34:42 -05:00
set_darkmode,
2022-02-25 13:56:09 -05:00
set_normals_color,
set_normals_length,
set_surface_radians,
2022-04-18 15:49:52 -04:00
set_surface_radius,
set_wireframe_opacity,
set_wireframe_fog,
set_snap_value,
document_new,
document_open
2022-01-01 17:28:47 -05:00
});
init();
2021-12-26 20:01:56 -05:00
export {
version,
init,
log,
restore_space,
space_init,
load_files,
load_files_opt,
space_load,
key_once,
key_once_cancel,
store_meta,
update_meta,
object_visible,
object_meta,
object_destroy,
set_darkmode,
set_normals_length,
set_normals_color,
set_surface_radians,
set_surface_radius,
set_wireframe_opacity,
set_wireframe_fog,
set_snap_value,
document_new,
document_open
};