add user control over object import and detail

This commit is contained in:
Stewart Allen 2020-11-06 10:47:49 -05:00
commit 24dc8898f5
15 changed files with 382 additions and 291 deletions

5
app.js
View file

@ -242,6 +242,7 @@ const script = {
"geo/debug",
"geo/render",
"geo/point",
"geo/points",
"geo/slope",
"geo/line",
"geo/bounds",
@ -268,12 +269,13 @@ const script = {
"mode/sla/client",
"mode/cam/driver",
"mode/cam/client",
"mode/cam/tool",
"mode/laser/driver",
"kiri/layer",
"kiri/widget",
"kiri/print",
"kiri/codec",
"kiri/work",
"kiri/client",
"kiri/conf",
"kiri/main",
"kiri/init",
@ -294,6 +296,7 @@ const script = {
// "geo/wasm",
"geo/debug",
"geo/point",
"geo/points",
"geo/slope",
"geo/line",
"geo/bounds",

View file

@ -1,6 +1,6 @@
{
"name": "grid-apps",
"version": "2.3.D1",
"version": "2.3.D2",
"description": "grid.space 3d slice & modeling tools",
"author": "Stewart Allen <sa@grid.space>",
"license": "MIT",

View file

@ -349,7 +349,7 @@
// other values break cube-s9 (wtf)
precision_decimate : 0.05,
// decimate test over this many points
decimate_threshold : 100000,
decimate_threshold : 500000,
// Point.onLine precision distance (endpoints in Polygon.intersect)
precision_point_on_line : 0.01,
// Polygon.isEquivalent value for determining similar enough to test

129
src/geo/points.js Normal file
View file

@ -0,0 +1,129 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
"use strict";
(function() {
const BASE = self.base,
DBUG = BASE.debug,
CONF = BASE.config;
BASE.verticesToPoints = verticesToPoints;
BASE.pointsToVertices = pointsToVertices;
/**
* converts a geometry point array into a kiri point array
* with auto-decimation
*
* @param {Float32Array} array
* @returns {Array}
*/
function verticesToPoints(array, options) {
let parr = new Array(array.length / 3),
i = 0,
j = 0,
t = time(),
hash = {},
unique = 0,
passes = 0,
points,
oldpoints = parr.length,
newpoints;
// replace point objects with their equivalents
while (i < array.length) {
let p = BASE.newPoint(array[i++], array[i++], array[i++]),
k = p.key,
m = hash[k];
if (!m) {
m = p;
hash[k] = p;
unique++;
}
parr[j++] = m;
}
let {threshold, precision, maxpass} = options || {};
// threshold = point count for triggering decimation
// precision = under which points are merged
// maxpass = max number of decimations
threshold = threshold > 0 ? threshold : CONF.decimate_threshold;
precision = precision >= 0 ? precision : CONF.precision_decimate;
maxpass = maxpass >= 0 ? maxpass : 10;
// decimate until all point spacing > precision
if (maxpass && precision > 0.0)
while (parr.length > threshold) {
let lines = [], line, dec = 0;
for (i=0; i<oldpoints; ) {
let p1 = parr[i++],
p2 = parr[i++],
p3 = parr[i++];
lines.push( {p1:p1, p2:p2, d:Math.sqrt(p1.distToSq3D(p2))} );
lines.push( {p1:p1, p2:p3, d:Math.sqrt(p1.distToSq3D(p3))} );
lines.push( {p1:p2, p2:p3, d:Math.sqrt(p2.distToSq3D(p3))} );
}
// sort by ascending line length
lines.sort(function(a,b) {
return a.d - b.d
});
// create offset mid-points
for (i=0; i<lines.length; i++) {
line = lines[i];
if (line.d >= precision) break;
if (line.p1.op || line.p2.op) continue;
// todo skip dropping lines where either point is a "sharp" on 3 vectors
line.p1.op = line.p2.op = line.p1.midPointTo3D(line.p2);
dec++;
}
// exit if nothing to decimate
if (dec === 0) break;
passes++;
// create new facets
points = new Array(oldpoints);
newpoints = 0;
for (i=0; i<oldpoints; ) {
let p1 = parr[i++],
p2 = parr[i++],
p3 = parr[i++];
// drop facets with two offset points
if (p1.op && p1.op === p2.op) continue;
if (p1.op && p1.op === p3.op) continue;
if (p2.op && p2.op === p3.op) continue;
// otherwise emit altered facet
points[newpoints++] = p1.op || p1;
points[newpoints++] = p2.op || p2;
points[newpoints++] = p3.op || p3;
}
parr = points.slice(0,newpoints);
oldpoints = newpoints;
if (passes >= maxpass) {
break;
}
}
// if (passes) console.trace({passes, threshold, precision, maxpass});
if (passes) DBUG.log({
before: array.length / 3,
after: parr.length,
unique: unique,
decimations: passes,
time: (time() - t)
});
return parr;
}
function pointsToVertices(points) {
let vertices = new Float32Array(points.length * 3),
i = 0, vi = 0;
while (i < points.length) {
vertices[vi++] = points[i].x;
vertices[vi++] = points[i].y;
vertices[vi++] = points[i++].z;
}
return vertices;
}
})();

View file

@ -21,20 +21,24 @@ let loc = self.location,
* @param {Function} fn name of function in KIRI.worker
* @param {Object} data to send to server
* @param {Function} onreply function to call on reply messages
* @param {Boolean} async true of function returns many messages
* @param {Object[]} zerocopy array of objects to pass using zerocopy
*/
function send(fn, data, onreply, async, zerocopy) {
function send(fn, data, onreply, zerocopy) {
let seq = seqid++;
running[seq] = {fn:onreply, async:async||false};
running[seq] = { fn:onreply };
// console.log('send', data);
worker.postMessage({
seq: seq,
task: fn,
time: time(),
data: data
}, zerocopy);
try {
worker.postMessage({
seq: seq,
task: fn,
time: time(),
data: data
}, zerocopy);
} catch (error) {
console.trace('work send error', {data, error});
}
}
// code is running in the browser / client context
@ -89,10 +93,10 @@ KIRI.work = {
};
},
decimate : function(vertices, callback) {
let alert = KIRI.api.show.alert('decimating imported model', 1000);
decimate : function(vertices, options, callback) {
let alert = KIRI.api.show.alert('processing model', 1000);
vertices = vertices.buffer.slice(0);
send("decimate", vertices, function(output) {
send("decimate", {vertices, options}, function(output) {
KIRI.api.hide.alert(alert);
callback(output);
});
@ -151,13 +155,13 @@ KIRI.work = {
}, function(reply) {
if (reply.done || reply.error) delete slicing[widget.id];
callback(reply);
}, null, [vertices]);
}, [vertices]);
},
printSetup : function(settings, callback) {
send("printSetup", {settings:settings}, function(reply) {
callback(reply);
}, undefined);
});
},
printExport : function(settings, online, ondone) {
@ -194,7 +198,7 @@ KIRI.work = {
send("printGCode", {}, function(reply) {
callback(reply);
});
}, null, [vertices]);
}, [vertices]);
callback(reply);
});
}

View file

@ -614,7 +614,9 @@
units: "mm",
exportOcto: false,
exportGhost: false,
exportLocal: true
exportLocal: true,
decimate: true,
detail: "good"
},
// for passing temporary slice hints (topo currently)
synth: {},

View file

@ -4,56 +4,174 @@
(function() {
const KIRI = self.kiri, DP = Catalog.prototype;
const KIRI = self.kiri;
/**
* @constructor
*/
function Catalog(motodb, decimate) {
let store = this;
store.db = motodb;
store.files = {};
store.listeners = [];
store.autodec = decimate;
store.deferredHandler = null;
store.refresh();
}
class Catalog {
constructor(motodb, options) {
let store = this;
this.db = motodb;
this.files = {};
this.listeners = [];
this.options = options || {};
this.deferredHandler = null;
this.refresh();
}
KIRI.openCatalog = function(motodb,decimate) {
return new Catalog(motodb,decimate);
};
setOptions(options) {
this.options = options;
return this;
}
DP.refresh = function() {
let store = this;
store.db.get('files', function(files) {
if (files) {
store.files = files;
notifyFileListeners(store);
refresh() {
let store = this;
store.db.get('files', function(files) {
if (files) {
store.files = files;
notifyFileListeners(store);
}
});
};
wipe() {
let key, files = this.files;
for (key in files) {
if (files.hasOwnProperty(key)) this.deleteFile(key);
}
});
};
};
DP.wipe = function() {
let key, files = this.files;
for (key in files) {
if (files.hasOwnProperty(key)) this.deleteFile(key);
}
};
fileList() {
return this.files;
};
DP.fileList = function() {
return this.files;
};
addFileListener(listener) {
if (!this.listeners.contains(listener)) {
this.listeners.push(listener);
listener(this.files);
}
};
DP.addFileListener = function(listener) {
if (!this.listeners.contains(listener)) {
this.listeners.push(listener);
listener(this.files);
}
};
removeFileListener(listener) {
this.listeners.remove(listener);
};
DP.removeFileListener = function(listener) {
this.listeners.remove(listener);
};
decimate(vertices, callback) {
let options = this.options;
if (vertices.length < (options.threshold || 500000)) {
return callback(vertices);
}
KIRI.work.decimate(vertices, this.options, function(reply) {
callback(reply);
});
};
setDeferredHandler(handler) {
this.deferredHandler = handler;
};
putDeferred(name, mark) {
// triggers refresh callback
this.files[name] = {
deferred: mark
};
saveFileList(this);
};
/**
* @param {String} name
* @param {Float32Array} vertices
* @param {Function} [callback]
*/
putFile(name, vertices, callback) {
let store = this;
store.db.put('file-'+name, vertices, function(ok) {
if (ok) {
store.files[name] = {
vertices: vertices.length/3,
updated: new Date().getTime()
};
saveFileList(store);
store.decimate(vertices, function(decimated) {
store.db.put('fdec-'+name, decimated);
if (callback) callback(decimated);
});
} else if (callback) callback(ok);
});
};
rename(name, newname, callback) {
if (!this.files[name]) return callback({error: 'no such file'});
if (!newname || newname == name) return callback({error: 'invalid new name'});
let done = 0;
let error = [];
let store = this;
function complete(ok, err) {
if (err) error.push(err);
if (++done === 2) {
store.files[newname] = store.files[name];
delete store.files[name];
saveFileList(store);
store.db.remove(`fdec-${name}`);
store.db.remove(`file-${name}`);
callback(error.length ? {error} : {});
}
}
store.db.get(`fdec-${name}`, (vertices) => {
if (!vertices) return complete(false, 'no decimation');
store.db.put(`fdec-${newname}`, vertices, complete);
});
store.db.get(`file-${name}`, (vertices) => {
if (!vertices) return complete(false, 'no raw file');
store.db.put(`file-${newname}`, vertices, complete);
});
};
/**
* @param {String} name
* @param {Function} callback
*/
getFile(name, callback) {
let store = this,
rec = store.files[name];
if (rec && rec.deferred) {
if (store.deferredHandler) return store.deferredHandler(rec.deferred, name, callback);
return callback();
}
this.db.get('fdec-'+name, function(vertices) {
if (vertices) {
callback(vertices);
} else {
store.db.get('file-'+name, function(vertices) {
if (vertices) {
store.decimate(vertices, function(decimated) {
store.db.put('fdec-'+name, decimated);
callback(vertices);
});
} else {
return callback();
}
});
}
});
};
/**
* @param {String} name
* @param {Function} callback
*/
deleteFile(name, callback) {
let store = this;
if (store.files[name]) {
delete store.files[name];
store.db.remove('fdec-'+name);
store.db.remove('file-'+name, function(ok) {
saveFileList(store);
if (callback) callback(ok);
});
return;
}
if (callback) callback(false);
};
}
function saveFileList(store) {
store.db.put('files', store.files);
@ -66,125 +184,8 @@
}
}
DP.decimate = function(vertices, callback) {
if (vertices.length < 500000) return callback(vertices);
KIRI.work.decimate(vertices, function(reply) {
callback(reply);
});
};
DP.setDeferredHandler = function(handler) {
this.deferredHandler = handler;
};
DP.putDeferred = function(name, mark) {
// triggers refresh callback
this.files[name] = {
deferred: mark
};
saveFileList(this);
};
/**
* @param {String} name
* @param {Float32Array} vertices
* @param {Function} [callback]
*/
DP.putFile = function(name, vertices, callback) {
let store = this;
store.db.put('file-'+name, vertices, function(ok) {
if (ok) {
store.files[name] = {
vertices: vertices.length/3,
updated: new Date().getTime()
};
saveFileList(store);
if (store.autodec) {
store.decimate(vertices, function(decimated) {
store.db.put('fdec-'+name, decimated);
if (callback) callback(decimated);
});
} else if (callback) callback(ok);
} else if (callback) callback(ok);
});
};
DP.rename = function(name, newname, callback) {
if (!this.files[name]) return callback({error: 'no such file'});
if (!newname || newname == name) return callback({error: 'invalid new name'});
let done = 0;
let error = [];
let store = this;
function complete(ok, err) {
if (err) error.push(err);
if (++done === 2) {
store.files[newname] = store.files[name];
delete store.files[name];
saveFileList(store);
store.db.remove(`fdec-${name}`);
store.db.remove(`file-${name}`);
callback(error.length ? {error} : {});
}
}
store.db.get(`fdec-${name}`, (vertices) => {
if (!vertices) return complete(false, 'no decimation');
store.db.put(`fdec-${newname}`, vertices, complete);
});
store.db.get(`file-${name}`, (vertices) => {
if (!vertices) return complete(false, 'no raw file');
store.db.put(`file-${newname}`, vertices, complete);
});
};
/**
* @param {String} name
* @param {Function} callback
*/
DP.getFile = function(name, callback) {
let store = this,
rec = store.files[name];
if (rec && rec.deferred) {
if (store.deferredHandler) return store.deferredHandler(rec.deferred, name, callback);
return callback();
}
if (!this.autodec) {
store.db.get('file-'+name, callback);
return;
}
this.db.get('fdec-'+name, function(vertices) {
if (vertices) {
callback(vertices);
} else {
store.db.get('file-'+name, function(vertices) {
if (vertices) {
store.decimate(vertices, function(decimated) {
store.db.put('fdec-'+name, decimated);
callback(vertices);
});
} else {
return callback();
}
});
}
});
};
/**
* @param {String} name
* @param {Function} callback
*/
DP.deleteFile = function(name, callback) {
let store = this;
if (store.files[name]) {
delete store.files[name];
store.db.remove('fdec-'+name);
store.db.remove('file-'+name, function(ok) {
saveFileList(store);
if (callback) callback(ok);
});
return;
}
if (callback) callback(false);
KIRI.openCatalog = function(motodb, options) {
return new Catalog(motodb, options);
};
})();

View file

@ -85,6 +85,25 @@
platform.update_size();
}
function detailSave() {
let level = UI.detail.options[UI.detail.selectedIndex];
if (level) {
level = level.value;
let rez = BASE.config.clipperClean;
switch (level) {
case 'best': rez = 50; break;
case 'good': rez = BASE.config.clipperClean; break;
case 'fair': rez = 500; break;
case 'poor': rez = 1000; break;
}
KIRI.client.config({
base: { clipperClean: rez }
});
settings().controller.detail = level;
API.conf.save();
}
}
function booleanSave() {
let control = settings().controller;
let isDark = control.dark;
@ -101,12 +120,16 @@
control.exportOcto = UI.exportOcto.checked;
control.exportGhost = UI.exportGhost.checked;
control.exportLocal = UI.exportLocal.checked;
control.decimate = UI.decimate.checked;
SPACE.view.setZoom(control.reverseZoom, control.zoomSpeed);
platform.layout();
platform.update_stock();
API.conf.save();
API.mode.set_expert(control.expert);
API.platform.update_size();
API.catalog.setOptions({
maxpass: control.decimate ? 10 : 0
});
UC.setHoverPop(control.hoverPop);
}
@ -1349,10 +1372,15 @@
freeLayout: UC.newBoolean(LANG.op_free_s, booleanSave, {title:LANG.op_free_l}),
units: UC.newSelect(LANG.op_unit_s, {title: LANG.op_unit_l, action:unitsSave}, "units"),
export: UC.newGroup(LANG.xp_menu, $('prefs-out'), {inline: true}),
export: UC.newGroup(LANG.xp_menu, $('prefs-xpo'), {inline: true}),
exportOcto: UC.newBoolean(`OctoPrint`, booleanSave),
exportGhost: UC.newBoolean(`Grid:Host`, booleanSave),
exportLocal: UC.newBoolean(`Grid:Local`, booleanSave),
parts: UC.newGroup(LANG.pt_menu, $('prefs-prt'), {inline: true}),
detail: UC.newSelect(LANG.pt_qual_s, {title: LANG.pt_qual_l, action: detailSave}, "detail"),
decimate: UC.newBoolean(LANG.pt_deci_s, booleanSave, {title: LANG.pt_deci_l}),
prefadd: UC.checkpoint($('prefs-add')),
process: UC.newGroup(LANG.sl_menu, $('settings'), {modes:FDM_LASER}),
@ -1895,6 +1923,8 @@
UI.autoLayout.checked = control.autoLayout;
UI.alignTop.checked = control.alignTop;
UI.reverseZoom.checked = control.reverseZoom;
UI.decimate.checked = control.decimate;
detailSave();
// load script extensions
if (SETUP.s) SETUP.s.forEach(function(lib) {

View file

@ -5,7 +5,6 @@
(function () {
let iOS = /(iPad|iPhone|iPod)/g.test(navigator.userAgent),
autoDecimate = true,
// ---------------
MOTO = moto,
KIRI = self.kiri,
@ -24,7 +23,7 @@
ODB = KIRI.odb = new MOTO.Storage(SETUP.d ? SETUP.d[0] : 'kiri'),
SPACE = KIRI.space = MOTO.Space,
WIDGETS = KIRI.widgets = [],
CATALOG = KIRI.catalog = KIRI.openCatalog(ODB,autoDecimate),
CATALOG = KIRI.catalog = KIRI.openCatalog(ODB),
STATS = new Stats(SDB),
SEED = 'kiri-seed',
// ---------------
@ -149,6 +148,12 @@
{ name: "none" },
{ name: "x axis" },
{ name: "y axis" }
],
detail: [
{ name: "best" },
{ name: "good" },
{ name: "fair" },
{ name: "poor" },
]
};
@ -1444,17 +1449,20 @@
let source = uie.parentNode.getAttribute('source'),
list = settings[source] || lists[source],
chosen = null;
if (list) list.forEach(function(tool, index) {
let id = tool.id || tool.name;
if (list) list.forEach(function(el, index) {
let id = el.id || el.name;
let ev = el.value || id;
if (val == id) {
chosen = index;
}
let opt = DOC.createElement('option');
opt.appendChild(DOC.createTextNode(tool.name));
opt.setAttribute('value', id);
opt.appendChild(DOC.createTextNode(el.name));
opt.setAttribute('value', ev);
uie.appendChild(opt);
});
if (chosen) uie.selectedIndex = chosen;
if (chosen) {
uie.selectedIndex = chosen;
}
} else if (typ === 'textarea') {
if (Array.isArray(val)) {
uie.value = val.join('\n');

View file

@ -14,7 +14,6 @@
UTIL = BASE.util,
POLY = BASE.polygons,
MATH = Math,
SQRT = MATH.sqrt,
PRO = Widget.prototype,
time = UTIL.time,
solid_opacity = 1.0;
@ -190,104 +189,6 @@
KIRI.odb.remove('ws-save-'+id, ondone);
};
/**
* converts a geometry point array into a kiri point array
* with auto-decimation
*
* @param {Float32Array} array
* @param {boolean} [decimate]
* @returns {Array}
*/
Widget.verticesToPoints = function(array,decimate) {
let parr = new Array(array.length / 3),
i = 0,
j = 0,
t = time(),
hash = {},
unique = 0,
passes = 0,
points,
oldpoints = parr.length,
newpoints;
// replace point objects with their equivalents
while (i < array.length) {
let p = BASE.newPoint(array[i++], array[i++], array[i++]),
k = p.key,
m = hash[k];
if (!m) {
m = p;
hash[k] = p;
unique++;
}
parr[j++] = m;
}
// decimate until all point spacing > precision_decimate
while (parr.length > BASE.config.decimate_threshold && decimate && BASE.config.precision_decimate > 0.0) {
let lines = [], line, dec = 0;
for (i=0; i<oldpoints; ) {
let p1 = parr[i++],
p2 = parr[i++],
p3 = parr[i++];
lines.push( {p1:p1, p2:p2, d:SQRT(p1.distToSq3D(p2))} );
lines.push( {p1:p1, p2:p3, d:SQRT(p1.distToSq3D(p3))} );
lines.push( {p1:p2, p2:p3, d:SQRT(p2.distToSq3D(p3))} );
}
// sort by ascending line length
lines.sort(function(a,b) {
return a.d - b.d
});
// create offset mid-points
for (i=0; i<lines.length; i++) {
line = lines[i];
if (line.d >= BASE.config.precision_decimate) break;
if (line.p1.op || line.p2.op) continue;
// todo skip dropping lines where either point is a "sharp" on 3 vectors
line.p1.op = line.p2.op = line.p1.midPointTo3D(line.p2);
dec++;
}
// exit if nothing to decimate
if (dec === 0) break;
passes++;
// create new facets
points = new Array(oldpoints);
newpoints = 0;
for (i=0; i<oldpoints; ) {
let p1 = parr[i++],
p2 = parr[i++],
p3 = parr[i++];
// drop facets with two offset points
if (p1.op && p1.op === p2.op) continue;
if (p1.op && p1.op === p3.op) continue;
if (p2.op && p2.op === p3.op) continue;
// otherwise emit altered facet
points[newpoints++] = p1.op || p1;
points[newpoints++] = p2.op || p2;
points[newpoints++] = p3.op || p3;
}
parr = points.slice(0,newpoints);
oldpoints = newpoints;
}
if (passes) DBUG.log({
before: array.length / 3,
after: parr.length,
unique: unique,
decimations: passes,
time: (time() - t)
});
return parr;
};
Widget.pointsToVertices = function(points) {
let vertices = new Float32Array(points.length * 3),
i = 0, vi = 0;
while (i < points.length) {
vertices[vi++] = points[i].x;
vertices[vi++] = points[i].y;
vertices[vi++] = points[i++].z;
}
return vertices;
};
/** ******************************************************************
* Widget Prototype Functions
******************************************************************* */
@ -601,7 +502,9 @@
PRO.getPoints = function() {
if (!this.points) {
// convert and cache points from geometry vertices
this.points = Widget.verticesToPoints(this.getGeoVertices());
this.points = BASE.verticesToPoints(this.getGeoVertices(), {
maxpass: 0 // disable decimation
});
}
return this.points;
};

View file

@ -5,7 +5,6 @@
let BASE = self.base,
KIRI = self.kiri,
UTIL = BASE.util,
Widget = KIRI.Widget,
time = UTIL.time,
current = self.worker = {
print: null,
@ -27,9 +26,10 @@ KIRI.server =
KIRI.worker = {
cache: cache,
decimate: function(vertices, send) {
decimate: function(data, send) {
let { vertices, options } = data;
vertices = new Float32Array(vertices),
vertices = Widget.pointsToVertices(Widget.verticesToPoints(vertices, true));
vertices = BASE.pointsToVertices(BASE.verticesToPoints(vertices, options));
send.done(vertices);
},
@ -43,7 +43,7 @@ KIRI.worker = {
vertices = new Float32Array(data.vertices),
position = data.position,
tracking = data.tracking,
points = Widget.verticesToPoints(vertices),
points = BASE.verticesToPoints(vertices, { maxpass: 0 }),
state = data.state || {},
rotation = state.rotation,
centerz = state.centerz,

View file

@ -3,7 +3,7 @@
let terms = {
COPYRIGHT: "Copyright (C) Stewart Allen <sa@grid.space> - All Rights Reserved",
LICENSE: "See the license.md file included with the source distribution",
VERSION: "2.3.D1"
VERSION: "2.3.D2"
};
if (typeof(module) === 'object') {

View file

@ -100,5 +100,8 @@
}
CAM.Tool = Tool;
CAM.getToolDiameter = function(settings, id) {
return new CAM.Tool(settings, id).fluteDiameter();
};
})();

View file

@ -308,7 +308,9 @@
<div class="t-pad2"></div>
<div id="prefs-lay" class="f-col"></div>
<div class="t-pad2"></div>
<div id="prefs-out" class="f-col"></div>
<div id="prefs-xpo" class="f-col"></div>
<div class="t-pad2"></div>
<div id="prefs-prt" class="f-col"></div>
<div class="t-pad2"></div>
<div id="prefs-add" class="f-col"></div>
<div class="t-pad2"></div>

View file

@ -112,7 +112,7 @@ kiri.lang['en-us'] = {
ws_cler: "Clear",
// OPTIONS
op_menu: "preferences",
op_menu: "interface",
op_xprt_s: "expert",
op_xprt_l: "enable expert options",
op_hopo_s: "hover pop",
@ -140,6 +140,12 @@ kiri.lang['en-us'] = {
lo_menu: "layout",
pt_menu: "parts",
pt_deci_s: "decimate",
pt_deci_l: "enable or disable\npoint decimation\nfor faster slicing",
pt_qual_s: "quality",
pt_qual_l: "level of detail\nto retain on\nimported part",
xp_menu: "exports",
// LAYERS pop-menu