mesh v2 at /v2/mesh

This commit is contained in:
Stewart Allen 2025-06-14 01:05:08 -04:00
commit 6566c45f9a
85 changed files with 138198 additions and 1 deletions

6
app.js
View file

@ -330,6 +330,8 @@ function init(mod) {
mod.static("/meta/", "web/meta");
mod.static("/kiri/", "web/kiri");
mod.static("/v2/", "web2");
function load_modules(root, force) {
// load modules
lastmod(`${dir}/${root}`) && fs.readdirSync(`${dir}/${root}`).forEach(mdir => {
@ -890,7 +892,9 @@ function fixedmap(prefix, map) {
// HTTP 307 redirect
function redir(path, type) {
return (req, res, next) => http.redirect(res, path, type);
return (req, res, next) => {
http.redirect(res, path, type);
}
}
// mangle request path

View file

@ -95,6 +95,7 @@
"prebuild": "node bin/electron-pre.js",
"postbuild": "node bin/electron-post.js",
"preinstall": "node bin/install-pre.js && npx webpack --config bin/webpack-three.js",
"preinstall2": "npx webpack --config src2/webpack/webpack-three-esm.js",
"docs-dev": "docusaurus start",
"docs-build": "docusaurus build",
"docs-serve": "docusaurus serve"

262
src2/add/array.js Normal file
View file

@ -0,0 +1,262 @@
/** Copyright 2014-2019 Stewart Allen -- All Rights Reserved */
// extend Array, String, Number, Math
const AP = {};
if (!AP.flat) {
AP.flat = function() {
try {
return [].concat.apply([], this);
} catch (e) {
// console.log({flat_error: e});
let out = [];
for (let e of this.slice()) {
out.push(...e);
}
return out;
}
};
}
AP.findWith = function(fn) {
for (let i=0, l=this.length; i<l; i++) {
if (fn(this[i])) {
return i;
}
}
return -1;
};
AP.equals = function(arr) {
if (!arr) return false;
if (arr.length !== this.length) return false;
for (let i = 0; i < this.length; i++) {
if (arr[i] !== this[i]) return false;
}
return true;
};
AP.peek = function(val = 1) {
return this[this.length - val];
};
AP.drop = function(pre = 0, post = 0) {
while (pre-- > 0) this.shift();
while (post-- > 0) this.pop();
return this;
};
/**
* allow chaining of push() calls
*/
AP.append = function(v, flat) {
if (flat) {
return this.appendAll(v);
} else {
this.push(v);
return this;
}
};
/**
* append all array elements to this array
*/
AP.appendAll = function(arr) {
if (arr && arr.length > 0) {
// avoid hitting stack limits
if (arr.length > 10000) {
for (let i = 0, il = arr.length; i < il; i++) {
this.push(arr[i]);
}
} else {
// this.push.apply(this,arr); (slower?)
this.push(...arr);
}
}
return this;
};
AP.addOnce = function(val) {
if (!this.contains(val)) {
this.push(val);
}
return this;
};
/**
* shallow cloning with clone(arg) call on each new element
*/
AP.clone = function() {
return this.map(v => v.clone(...arguments));
};
AP.xray = function(arg) {
const na = [],
ln = this.length;
for (let i = 0; i < ln; i++) {
na.push(this[i].xray(arg));
}
return na;
};
AP.xout = function(label, inset) {
if (label) console.log(`${label} [${this.length}]`);
if (!inset) inset = ' ';
this.forEach((el, i) => {
if (el.xout) {
el.xout(null, inset + i + inset);
} else if (el.xray) {
const info = el.xray();
console.log(inset, info);
Object.values(info).forEach(val => {
if (val.xout) {
val.xout(null, inset + inset);
}
});
}
});
};
/**
* remove and return element from array, if present
*/
AP.remove = function(val) {
let idx = this.indexOf(val);
if (idx >= 0) return this.splice(idx, 1);
return null;
};
AP.removeAll = function(array) {
for (let i = 0, il = array.length; i < il; i++) {
this.remove(array[i]);
}
};
/**
* return last array element if array length > 0
*/
AP.last = function() {
if (this.length === 0) return null;
return this[this.length - 1];
};
AP.contains = function(val) {
return this.indexOf(val) >= 0;
};
AP.toFloat32 = function() {
return new Float32Array(this);
};
AP.toUint32 = function() {
return Uint32Array.from(this);
};
AP.forEachPair = function(fn, incr) {
let scope = this,
idx = 0,
inc = incr || 2,
len = scope.length;
while (idx < len) {
fn(scope[idx], scope[(idx + 1) % len], idx);
idx += inc;
}
};
AP.show = function(fn, f) {
f = f || function(v) {
console.log(v)
};
this.forEach(function(av) {
f(av[fn]());
})
};
AP.uniq = function() {
return this.slice().sort().filter((x, i, a) => !i || x != a[i - 1]);
};
AP.hasNaN = function() {
return this.filter(v => isNaN(v)).length > 0;
};
// turn array into arrays of specified size
AP.group = function(size) {
let na = new Array(Math.ceil(this.length / size));
let nai = 0;
for (let i = 0; i < this.length; i += size) {
let grp = new Array(size);
for (let j = 0; j < size; j++) {
grp[j] = this[i + j];
}
na[nai++] = grp;
}
return na;
};
// wrap a function and unroll the first parameter into
// successive function calls if it is an array
Array.handle = function(fn) {
return function() {
let args = [...arguments];
let val = args.shift();
if (Array.isArray(val)) {
let rv = [];
for (let v of val.slice()) {
rv.push(fn(v, ...args));
}
return rv;
} else {
return fn(val, ...args);
}
}
};
// Apply all methods to Array.prototype
Object.assign(Array.prototype, AP);
// Export for ES modules
export { AP as arrayPrototype };
Float32Array.prototype.toShared = function() {
const newvert = new Float32Array(new SharedArrayBuffer(this.buffer.byteLength));
newvert.set(this);
return newvert;
};
Float32Array.prototype.toFloat32 = function() { return this };
ArrayBuffer.prototype.textDecode = function(encoding = 'utf-8') {
return new TextDecoder(encoding).decode(this);
};
String.prototype.reverse = function() {
return this.split('').reverse().join('');
};
Object.clone = function(o) {
// not using structuredClone because failes with some objects
// this method "cleans" out non clonables
return o ? JSON.parse(JSON.stringify(o)) : o;
};
Object.toArray = function(o) {
let ret = [];
for (let [key, value] of Object.entries(o)) {
ret.push({
key,
value
});
}
return ret;
};
Math.bound = function(val, min, max) {
return Math.max(min, Math.min(max, val));
};
Number.prototype.round = function(digits) {
if (digits === 0) return this.valueOf() | 0;
const pow = Math.pow(10, digits || 3);
return Math.round(this.valueOf() * pow) / pow;
};

178
src2/add/class.js Normal file
View file

@ -0,0 +1,178 @@
/** Copyright 2014-2019 Stewart Allen -- All Rights Reserved */
// use: add.array
import { arrayPrototype } from './array.js';
// Make classes available globally
class ArrayWriter {
constructor() {
this.pos = 0;
this.array = [];
}
seek(pos) {
let last = this.pos;
this.pos = pos;
return last;
}
skip(len) {
this.writeBytes(new Array(len).fill(0), len);
return this.pos;
}
writeU8(v) {
this.array[this.pos++] = v & 0xff;
}
writeU16(v) {
this.array[this.pos++] = (v >> 8) & 0xff;
this.array[this.pos++] = v & 0xff;
}
writeU32(v) {
this.array[this.pos++] = (v >> 24) & 0xff;
this.array[this.pos++] = (v >> 16) & 0xff;
this.array[this.pos++] = (v >> 8) & 0xff;
this.array[this.pos++] = v & 0xff;
}
writeBytes(bytes) {
this.array.appendAll(bytes);
this.pos += bytes.length;
}
toBuffer() {
return new Uint8ClampedArray(this.array).buffer;
}
};
class DataWriter {
constructor(view, pos) {
this.view = view;
this.pos = pos || 0;
}
skip(v) {
let p = this.pos;
this.pos += v;
return p;
}
writeU8(v) {
try {
this.view.setUint8(this.pos, v);
return this.skip(1);
} catch (err) {
console.log({pos:this.pos, err})
throw err;
}
}
writeU16(v,le) {
this.view.setUint16(this.pos, v, le);
return this.skip(2);
}
writeU32(v,le) {
this.view.setUint32(this.pos, v, le);
return this.skip(4);
}
writeI32(v,le) {
this.view.setInt32(this.pos, v, le);
return this.skip(4);
}
writeUB64(v,le) {
this.view.setBigUint64(this.pos, v, le);
return this.skip(8);
}
writeB64(v,le) {
this.view.setBigInt64(this.pos, v, le);
return this.skip(8);
}
writeF32(v,le) {
this.view.setFloat32(this.pos, v, le);
return this.skip(4);
}
writeF64(v,le) {
this.view.setFloat64(this.pos, v, le);
return this.skip(8);
}
}
class DataReader {
constructor(view, pos) {
this.view = view;
this.pos = pos || 0;
}
skip(v) {
let p = this.pos;
this.pos += v;
return p;
}
readBytes(len) {
let buf = this.view.buffer.slice(this.pos, this.pos + len);
this.pos += len;
return buf;
}
readU8() {
return this.view.getUint8(this.pos++);
}
readU16(le) {
let v = this.view.getUint16(this.pos, le);
this.pos += 2;
return v;
}
readU32(le) {
let v = this.view.getUint32(this.pos, le);
this.pos += 4;
return v;
}
readI32(le) {
let v = this.view.getInt32(this.pos, le);
this.pos += 4;
return v;
}
readB64(le) {
let v = this.view.getBigInt64(this.pos, le);
this.pos += 8;
return v;
}
readUB64(le) {
let v = this.view.getBigUint64(this.pos, le);
this.pos += 8;
return v;
}
readF32(le) {
let v = this.view.getFloat32(this.pos, le);
this.pos += 4;
return v;
}
readF64(le) {
let v = this.view.getFloat64(this.pos, le);
this.pos += 8;
return v;
}
}
window.ArrayWriter = ArrayWriter;
window.DataWriter = DataWriter;
window.DataReader = DataReader;
// Export for ES modules
export { ArrayWriter, DataWriter, DataReader };

148
src2/add/three.js Normal file
View file

@ -0,0 +1,148 @@
/** Copyright 2014-2019 Stewart Allen -- All Rights Reserved */
// dep: ext.three
import {
THREE, SVGLoader, BufferGeometryUtils,
LineMaterial, Line2, LineGeometry, LineSegments2,
LineSegmentsGeometry, MeshBVHLib } from '../ext/three.js';
// Make THREE available globally
window.THREE = THREE;
THREE.BufferGeometryUtils = BufferGeometryUtils;
THREE.SVGLoader = SVGLoader.SVGLoader;
THREE.LineMaterial = LineMaterial;
THREE.Line2 = Line2;
THREE.LineGeometry = LineGeometry;
THREE.LineSegments2 = LineSegments2;
THREE.LineSegmentsGeometry = LineSegmentsGeometry;
let MP = THREE.Mesh.prototype,
XP = THREE.Box3.prototype,
BP = THREE.BufferGeometry.prototype;
THREE.computeFaceNormal = function(vA,vB,vC) {
const ab = new THREE.Vector3();
const cb = new THREE.Vector3();
cb.subVectors( vC, vB );
ab.subVectors( vA, vB );
cb.cross( ab );
cb.normalize();
return cb;
};
// https://www.npmjs.com/package/three-mesh-bvh
THREE.addBVH = function() {
const { computeBoundsTree, disposeBoundsTree, acceleratedRaycast } = MeshBVHLib;
THREE.BufferGeometry.prototype.computeBoundsTree = computeBoundsTree;
THREE.BufferGeometry.prototype.disposeBoundsTree = disposeBoundsTree;
THREE.Mesh.prototype.raycast = acceleratedRaycast;
};
THREE.dispose = function(obj) {
if (!obj) {
return;
}
if (obj.geometry) {
obj.geometry.dispose();
}
if (obj.material && obj.material.map && obj.material.map.dispose) {
obj.material.map.dispose();
}
if (obj.children && obj.children.length) {
for (let c of obj.children) {
THREE.dispose(c);
}
}
};
// add .dim and .mid getters to Box3
Object.defineProperties(XP, {
dim: {
get: function() {
return this.max.clone().sub(this.min);
}
},
mid: {
get: function() {
return this.dim.clone().multiplyScalar(0.5).add(this.min);
}
}
});
MP.getBoundingBox = function(update) {
return this.geometry.getBoundingBox(update);
};
MP.mirrorX = function() {
return this.mirror(0);
};
MP.mirrorY = function() {
return this.mirror(1);
};
MP.mirrorZ = function() {
return this.mirror(2);
};
// fast mirror of vertices given axis array offset
// array elements are repeating [x,y,z,x,y,z,...]
MP.mirror = function(start) {
let i,
geo = this.geometry,
at = geo.attributes,
pa = at.position.array,
nm = at.normal.array;
for (i = start || 0 ; i < pa.length; i += 3) {
pa[i] = -pa[i];
nm[i] = -nm[i];
}
geo.computeVertexNormals();
return this;
};
// return cached or refreshed (when update = true) bounding box
BP.getBoundingBox = function(update) {
if (update || !this.boundingBox) {
this.boundingBox = null;
this.computeBoundingBox();
}
return this.boundingBox;
};
BP.moveMesh = function(x = 0, y = 0, z = 0) {
let gap = this.attributes.position,
pa = gap.array;
for (let i=0; i < pa.length; i += 3) {
pa[i ] += x;
pa[i + 1] += y;
pa[i + 2] += z;
}
gap.needsUpdate = true;
return this;
}
BP.shallowClone = function() {
let geo = new THREE.BufferGeometry();
for (let [k, v] of Object.entries(this.attributes)) {
geo.setAttribute(k, new THREE.BufferAttribute(v.array, v.itemSize));
}
return geo;
};
THREE.Object3D.prototype.newGroup = function() {
let group = new THREE.Group();
this.add(group);
return group;
};
THREE.Object3D.prototype.removeAll = function() {
this.children.slice().forEach(function (c) {
c.parent = undefined;
c.dispatchEvent( { type: 'removed' } );
});
this.children = [];
};
// Export for ES modules
export { THREE };

1
src2/ext/base64.js Symbolic link
View file

@ -0,0 +1 @@
../../node_modules/base64-js/base64js.min.js

6986
src2/ext/clip.js Executable file

File diff suppressed because it is too large Load diff

7
src2/ext/clip2.esm.js Normal file
View file

@ -0,0 +1,7 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
// Import the original Clipper library
import './clip2.js';
// Re-export the ClipperLib object
export const ClipperLib = self.ClipperLib;

7629
src2/ext/clip2.js Normal file

File diff suppressed because it is too large Load diff

1
src2/ext/earcut.js Symbolic link
View file

@ -0,0 +1 @@
../../node_modules/earcut/dist/earcut.dev.js

247
src2/ext/fsave.js Normal file
View file

@ -0,0 +1,247 @@
/* FileSaver.js
* A saveAs() FileSaver implementation.
* 1.1.20151003
*
* By Eli Grey, http://eligrey.com
* License: MIT
* See https://github.com/eligrey/FileSaver.js/blob/master/LICENSE.md
*/
/*global self */
/*jslint bitwise: true, indent: 4, laxbreak: true, laxcomma: true, smarttabs: true, plusplus: true */
/*! @source http://purl.eligrey.com/github/FileSaver.js/blob/master/FileSaver.js */
"use strict";
// IE <10 is explicitly unsupported
if (typeof navigator !== "undefined" && /MSIE [1-9]\./.test(navigator.userAgent)) {
return;
}
const doc = self.document;
// only get URL when necessary in case Blob.js hasn't overridden it yet
const get_URL = function() {
return self.URL || self.webkitURL || self;
};
const save_link = doc.createElementNS("http://www.w3.org/1999/xhtml", "a");
const can_use_save_link = "download" in save_link;
const click = function(node) {
const event = new MouseEvent("click");
node.dispatchEvent(event);
};
const is_safari = /Version\/[\d\.]+.*Safari/.test(navigator.userAgent);
const webkit_req_fs = self.webkitRequestFileSystem;
const req_fs = self.requestFileSystem || webkit_req_fs || self.mozRequestFileSystem;
const throw_outside = function(ex) {
(self.setImmediate || self.setTimeout)(function() {
throw ex;
}, 0);
};
const force_saveable_type = "application/octet-stream";
const fs_min_size = 0;
// See https://code.google.com/p/chromium/issues/detail?id=375297#c7 and
// https://github.com/eligrey/FileSaver.js/commit/485930a#commitcomment-8768047
// for the reasoning behind the timeout and revocation flow
const arbitrary_revoke_timeout = 500; // in ms
const revoke = function(file) {
const revoker = function() {
if (typeof file === "string") { // file is an object URL
get_URL().revokeObjectURL(file);
} else { // file is a File
file.remove();
}
};
if (self.chrome) {
revoker();
} else {
setTimeout(revoker, arbitrary_revoke_timeout);
}
};
const dispatch = function(filesaver, event_types, event) {
event_types = [].concat(event_types);
let i = event_types.length;
while (i--) {
const listener = filesaver["on" + event_types[i]];
if (typeof listener === "function") {
try {
listener.call(filesaver, event || filesaver);
} catch (ex) {
throw_outside(ex);
}
}
}
};
const auto_bom = function(blob) {
// prepend BOM for UTF-8 XML and text/* types (including HTML)
if (/^\s*(?:text\/\S*|application\/xml|\S*\/\S*\+xml)\s*;.*charset\s*=\s*utf-8/i.test(blob.type)) {
return new Blob(["\ufeff", blob], {type: blob.type});
}
return blob;
};
class FileSaver {
constructor(blob, name, no_auto_bom) {
if (!no_auto_bom) {
blob = auto_bom(blob);
}
// First try a.download, then web filesystem, then object URLs
const filesaver = this;
const type = blob.type;
let blob_changed = false;
let object_url;
let target_view;
const dispatch_all = function() {
dispatch(filesaver, "writestart progress write writeend".split(" "));
};
// on any filesys errors revert to saving with object URLs
const fs_error = function() {
if (target_view && is_safari && typeof FileReader !== "undefined") {
// Safari doesn't allow downloading of blob urls
const reader = new FileReader();
reader.onloadend = function() {
const base64Data = reader.result;
target_view.location.href = "data:attachment/file" + base64Data.slice(base64Data.search(/[,;]/));
filesaver.readyState = filesaver.DONE;
dispatch_all();
};
reader.readAsDataURL(blob);
filesaver.readyState = filesaver.INIT;
return;
}
// don't create more object URLs than needed
if (blob_changed || !object_url) {
object_url = get_URL().createObjectURL(blob);
}
if (target_view) {
target_view.location.href = object_url;
} else {
const new_tab = self.open(object_url, "_blank");
if (new_tab == undefined && is_safari) {
//Apple do not allow window.open, see http://bit.ly/1kZffRI
self.location.href = object_url;
}
}
filesaver.readyState = filesaver.DONE;
dispatch_all();
revoke(object_url);
};
const abortable = function(func) {
return function() {
if (filesaver.readyState !== filesaver.DONE) {
return func.apply(this, arguments);
}
};
};
const create_if_not_found = {create: true, exclusive: false};
let slice;
filesaver.readyState = filesaver.INIT;
if (!name) {
name = "download";
}
if (can_use_save_link) {
object_url = get_URL().createObjectURL(blob);
setTimeout(function() {
save_link.href = object_url;
save_link.download = name;
click(save_link);
dispatch_all();
revoke(object_url);
filesaver.readyState = filesaver.DONE;
});
return;
}
// Object and web filesystem URLs have a problem saving in Google Chrome when
// viewed in a tab, so I force save with application/octet-stream
// http://code.google.com/p/chromium/issues/detail?id=91158
// Update: Google errantly closed 91158, I submitted it again:
// https://code.google.com/p/chromium/issues/detail?id=389642
if (self.chrome && type && type !== force_saveable_type) {
slice = blob.slice || blob.webkitSlice;
blob = slice.call(blob, 0, blob.size, force_saveable_type);
blob_changed = true;
}
// Since I can't be sure that the guessed media type will trigger a download
// in WebKit, I append .download to the filename.
// https://bugs.webkit.org/show_bug.cgi?id=65440
if (webkit_req_fs && name !== "download") {
name += ".download";
}
if (type === force_saveable_type || webkit_req_fs) {
target_view = self;
}
if (!req_fs) {
fs_error();
return;
}
fs_min_size += blob.size;
req_fs(self.TEMPORARY, fs_min_size, abortable(function(fs) {
fs.root.getDirectory("saved", create_if_not_found, abortable(function(dir) {
const save = function() {
dir.getFile(name, create_if_not_found, abortable(function(file) {
file.createWriter(abortable(function(writer) {
writer.onwriteend = function(event) {
target_view.location.href = file.toURL();
filesaver.readyState = filesaver.DONE;
dispatch(filesaver, "writeend", event);
revoke(file);
};
writer.onerror = function() {
const error = writer.error;
if (error.code !== error.ABORT_ERR) {
fs_error();
}
};
"writestart progress write abort".split(" ").forEach(function(event) {
writer["on" + event] = filesaver["on" + event];
});
writer.write(blob);
filesaver.abort = function() {
writer.abort();
filesaver.readyState = filesaver.DONE;
};
filesaver.readyState = filesaver.WRITING;
}), fs_error);
}), fs_error);
};
dir.getFile(name, {create: false}, abortable(function(file) {
// delete file if it already exists
file.remove();
save();
}), abortable(function(ex) {
if (ex.code === ex.NOT_FOUND_ERR) {
save();
} else {
fs_error();
}
}));
}), fs_error);
}), fs_error);
}
static get DONE() { return 2; }
static get INIT() { return 0; }
static get WRITING() { return 1; }
}
export function saveAs(blob, name, no_auto_bom) {
return new FileSaver(blob, name, no_auto_bom);
}
export { FileSaver };

1
src2/ext/gerber.js Symbolic link
View file

@ -0,0 +1 @@
../../node_modules/@tracespace/parser/umd/parser.js

1
src2/ext/jszip.js Symbolic link
View file

@ -0,0 +1 @@
../../node_modules/jszip/dist/jszip.js

1
src2/ext/manifold.js Symbolic link
View file

@ -0,0 +1 @@
../../node_modules/manifold-3d/manifold.js

67
src2/ext/md5.js Normal file
View file

@ -0,0 +1,67 @@
// SOURCE: https://stackoverflow.com/questions/1655769/fastest-md5-implementation-in-javascript
export function hash(e) {
function h(a, b) {
var c, d, e, f, g;
e = a & 2147483648;
f = b & 2147483648;
c = a & 1073741824;
d = b & 1073741824;
g = (a & 1073741823) + (b & 1073741823);
return c & d ? g ^ 2147483648 ^ e ^ f : c | d ? g & 1073741824 ? g ^ 3221225472 ^ e ^ f : g ^ 1073741824 ^ e ^ f : g ^ e ^ f
}
function k(a, b, c, d, e, f, g) {
a = h(a, h(h(b & c | ~b & d, e), g));
return h(a << f | a >>> 32 - f, b)
}
function l(a, b, c, d, e, f, g) {
a = h(a, h(h(b & d | c & ~d, e), g));
return h(a << f | a >>> 32 - f, b)
}
function m(a, b, d, c, e, f, g) {
a = h(a, h(h(b ^ d ^ c, e), g));
return h(a << f | a >>> 32 - f, b)
}
function n(a, b, d, c, e, f, g) {
a = h(a, h(h(d ^ (b | ~c), e), g));
return h(a << f | a >>> 32 - f, b)
}
function p(a) {
var b = "",
d = "",
c;
for (c = 0; 3 >= c; c++) d = a >>> 8 * c & 255, d = "0" + d.toString(16), b += d.substr(d.length - 2, 2);
return b
}
var f = [],
q, r, s, t, a, b, c, d;
e = function(a) {
a = a.replace(/\r\n/g, "\n");
for (var b = "", d = 0; d < a.length; d++) {
var c = a.charCodeAt(d);
128 > c ? b += String.fromCharCode(c) : (127 < c && 2048 > c ? b += String.fromCharCode(c >> 6 | 192) : (b += String.fromCharCode(c >> 12 | 224), b += String.fromCharCode(c >> 6 & 63 | 128)), b += String.fromCharCode(c & 63 | 128))
}
return b
}(e);
f = function(b) {
var a, c = b.length;
a = c + 8;
for (var d = 16 * ((a - a % 64) / 64 + 1), e = Array(d - 1), f = 0, g = 0; g < c;) a = (g - g % 4) / 4, f = g % 4 * 8, e[a] |= b.charCodeAt(g) << f, g++;
a = (g - g % 4) / 4;
e[a] |= 128 << g % 4 * 8;
e[d - 2] = c << 3;
e[d - 1] = c >>> 29;
return e
}(e);
a = 1732584193;
b = 4023233417;
c = 2562383102;
d = 271733878;
for (e = 0; e < f.length; e += 16) q = a, r = b, s = c, t = d, a = k(a, b, c, d, f[e + 0], 7, 3614090360), d = k(d, a, b, c, f[e + 1], 12, 3905402710), c = k(c, d, a, b, f[e + 2], 17, 606105819), b = k(b, c, d, a, f[e + 3], 22, 3250441966), a = k(a, b, c, d, f[e + 4], 7, 4118548399), d = k(d, a, b, c, f[e + 5], 12, 1200080426), c = k(c, d, a, b, f[e + 6], 17, 2821735955), b = k(b, c, d, a, f[e + 7], 22, 4249261313), a = k(a, b, c, d, f[e + 8], 7, 1770035416), d = k(d, a, b, c, f[e + 9], 12, 2336552879), c = k(c, d, a, b, f[e + 10], 17, 4294925233), b = k(b, c, d, a, f[e + 11], 22, 2304563134), a = k(a, b, c, d, f[e + 12], 7, 1804603682), d = k(d, a, b, c, f[e + 13], 12, 4254626195), c = k(c, d, a, b, f[e + 14], 17, 2792965006), b = k(b, c, d, a, f[e + 15], 22, 1236535329), a = l(a, b, c, d, f[e + 1], 5, 4129170786), d = l(d, a, b, c, f[e + 6], 9, 3225465664), c = l(c, d, a, b, f[e + 11], 14, 643717713), b = l(b, c, d, a, f[e + 0], 20, 3921069994), a = l(a, b, c, d, f[e + 5], 5, 3593408605), d = l(d, a, b, c, f[e + 10], 9, 38016083), c = l(c, d, a, b, f[e + 15], 14, 3634488961), b = l(b, c, d, a, f[e + 4], 20, 3889429448), a = l(a, b, c, d, f[e + 9], 5, 568446438), d = l(d, a, b, c, f[e + 14], 9, 3275163606), c = l(c, d, a, b, f[e + 3], 14, 4107603335), b = l(b, c, d, a, f[e + 8], 20, 1163531501), a = l(a, b, c, d, f[e + 13], 5, 2850285829), d = l(d, a, b, c, f[e + 2], 9, 4243563512), c = l(c, d, a, b, f[e + 7], 14, 1735328473), b = l(b, c, d, a, f[e + 12], 20, 2368359562), a = m(a, b, c, d, f[e + 5], 4, 4294588738), d = m(d, a, b, c, f[e + 8], 11, 2272392833), c = m(c, d, a, b, f[e + 11], 16, 1839030562), b = m(b, c, d, a, f[e + 14], 23, 4259657740), a = m(a, b, c, d, f[e + 1], 4, 2763975236), d = m(d, a, b, c, f[e + 4], 11, 1272893353), c = m(c, d, a, b, f[e + 7], 16, 4139469664), b = m(b, c, d, a, f[e + 10], 23, 3200236656), a = m(a, b, c, d, f[e + 13], 4, 681279174), d = m(d, a, b, c, f[e + 0], 11, 3936430074), c = m(c, d, a, b, f[e + 3], 16, 3572445317), b = m(b, c, d, a, f[e + 6], 23, 76029189), a = m(a, b, c, d, f[e + 9], 4, 3654602809), d = m(d, a, b, c, f[e + 12], 11, 3873151461), c = m(c, d, a, b, f[e + 15], 16, 530742520), b = m(b, c, d, a, f[e + 2], 23, 3299628645), a = n(a, b, c, d, f[e + 0], 6, 4096336452), d = n(d, a, b, c, f[e + 7], 10, 1126891415), c = n(c, d, a, b, f[e + 14], 15, 2878612391), b = n(b, c, d, a, f[e + 5], 21, 4237533241), a = n(a, b, c, d, f[e + 12], 6, 1700485571), d = n(d, a, b, c, f[e + 3], 10, 2399980690), c = n(c, d, a, b, f[e + 10], 15, 4293915773), b = n(b, c, d, a, f[e + 1], 21, 2240044497), a = n(a, b, c, d, f[e + 8], 6, 1873313359), d = n(d, a, b, c, f[e + 15], 10, 4264355552), c = n(c, d, a, b, f[e + 6], 15, 2734768916), b = n(b, c, d, a, f[e + 13], 21, 1309151649), a = n(a, b, c, d, f[e + 4], 6, 4149444226), d = n(d, a, b, c, f[e + 11], 10, 3174756917), c = n(c, d, a, b, f[e + 2], 15, 718787259), b = n(b, c, d, a, f[e + 9], 21, 3951481745), a = h(a, q), b = h(b, r), c = h(c, s), d = h(d, t);
return (p(a) + p(b) + p(c) + p(d)).toLowerCase()
}

7
src2/ext/pngjs.esm.js Normal file
View file

@ -0,0 +1,7 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
// Import the original PNG library
import './pngjs.js';
// Re-export the PNG object
export const PNG = self.png;

16433
src2/ext/pngjs.js Normal file

File diff suppressed because it is too large Load diff

80716
src2/ext/three.js Normal file

File diff suppressed because one or more lines are too long

1
src2/ext/tween.js Symbolic link
View file

@ -0,0 +1 @@
../../node_modules/@tweenjs/tween.js/src/Tween.js

852
src2/geo/base.js Normal file
View file

@ -0,0 +1,852 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { newPoint } from './point.js';
const round_decimal_precision = 5;
function time() {
return Date.now()
}
function lerp(from, to, maxInc, incFrom) {
let dir = Math.sign(to - from);
let delta = Math.abs(to - from);
let steps = Math.floor(delta / maxInc);
let rem = delta % maxInc;
let per = delta / steps;
if (rem) {
steps++;
per = delta / steps;
}
let out = incFrom ? [from] : [];
while (steps-- > 0) {
from += per * dir;
out.push(from);
}
return out;
}
/** track an array of promises as they all complete */
async function pwait(promises, tracker) {
let count = 0;
if (tracker)
for (let p of promises) {
p.then(data => {
tracker(count++, promises.length, data);
});
}
await Promise.all(promises);
}
/** return a promise that resolves after a given time */
function ptimer(time) {
return new Promise((resolve, reject) => {
setTimeout(resolve, time);
});
}
function numOrDefault(num, def) {
return num !== undefined ? num : def;
}
/**
* call function with all combinations of a1, a2
* and passing in the supplied arg object.
* used by polygons.trimTo() for trimming slice projected fills
* @param {Array} a1
* @param {Array} a2
* @param {Object} arg
* @param {Function} fn
* @returns {Object}
*/
function doCombinations(a1, a2, arg, fn) {
let i, j;
for (i = 0; i < a1.length; i++) {
for (j = (a1 === a2 ? i + 1 : 0); j < a2.length; j++) {
fn(a1[i], a2[j], arg);
}
}
return arg;
}
function isClockwise(p1, p2, p3) {
return area2(p1, p2, p3) > 0;
}
function isCounterClockwise(p1, p2, p3) {
return area2(p1, p2, p3) < 0;
}
function pac(p1, p2) {
return (p2.x - p1.x) * (p2.y + p1.y);
}
/**
* returns 2x area for a triangle with sign indicating handedness
* @returns {number} negative for CCW progression, positive for CW progression
*/
function area2(p1, p2, p3) {
return pac(p1, p2) + pac(p2, p3) + pac(p3, p1);
}
function isCloseTo(v1, v2, dist) {
return Math.abs(v1 - v2) <= (dist || base.config.precision_merge);
}
function inCloseRange(val, min, max) {
return (isCloseTo(val, min) || val >= min) && (isCloseTo(val, max) || val <= max);
}
/**
* return square of value
*/
function sqr(v) {
return v * v
}
// radians rotatition around origin
function rotate(x,y,radians) {
return [
x * Math.cos(radians) - y * Math.sin(radians),
y * Math.cos(radians) + x * Math.sin(radians)
];
}
const deg2rad = (Math.PI / 180);
const rad2deg = (180 / Math.PI);
function toRadians(degrees) {
return degrees * deg2rad;
}
function toDegrees(radians) {
return radians * rad2deg;
}
/**
* return distance between two points
*/
function dist2D(p1, p2) {
return Math.sqrt(distSq(p1, p2));
}
/**
* return distance squared between two points
*/
function distSq(p1, p2) {
return sqr(p2.x - p1.x) + sqr(p2.y - p1.y)
}
/**
* return distance squared between two points
* enables faster Point.nearPolygon()
*/
function distSqv2(x1, y1, x2, y2) {
return sqr(x2 - x1) + sqr(y2 - y1)
}
function offsetPrecision(offset, precision) {
return Math.abs(offset) - precision;
}
function inRange(value, min, max) {
let val = parseFloat(value);
return val >= min && val <= max;
}
function round(v, zeros) {
if (typeof v === 'object') {
for (let [key,val] of Object.entries(v)) {
if (typeof val === 'number') {
v[key] = round(val, zeros);
}
}
return v;
}
const prec = zeros !== undefined ? zeros : round_decimal_precision;
if (prec === 0) return v | 0;
let pow = Math.pow(10, prec);
return Math.round(v * pow) / pow;
}
function clamp(val, low, hi) {
return Math.max(low, Math.min(hi, val));
}
/**
* used by {@link Polygon.trace} and {@link Polygon.intersect}
*/
function intersect(p1, p2, p3, p4, test, parallelok) {
let keys = base.key,
p1x = p1.x,
p1y = p1.y,
p2x = p2.x,
p2y = p2.y,
p3x = p3.x,
p3y = p3.y,
p4x = p4.x,
p4y = p4.y,
d1x = (p2x - p1x), // ad.x
d1y = (p2y - p1y), // ad.y
d2x = (p4x - p3x), // bd.x
d2y = (p4y - p3y), // bd.y
d = (d2y * d1x) - (d2x * d1y); // det
//if (Math.abs(d) < 0.0000000001) {
if (Math.abs(d) < 0.0001) {
// lines are parallel or collinear
return test && !parallelok ? null : keys.PARALLEL;
}
let a = p1y - p3y, // origin dy
b = p1x - p3x, // origin dx
n1 = (d2x * a) - (d2y * b),
n2 = (d1x * a) - (d1y * b);
a = n1 / d; // roughly distance from l1 origin to l2 intersection
b = n2 / d; // roughly distance from l2 origin to l1 intersection
let ia = a >= -0.0001 && a <= 1.0001,
ib = b >= -0.0001 && b <= 1.0001,
segint = (ia && ib),
rayint = (a >= 0 && b >= 0);
if (test === keys.SEGINT && !segint) return null;
if (test === keys.RAYINT && !rayint) return null;
let ip = newPoint(
p1x + (a * d1x), // x
p1y + (a * d1y), // y
p3.z || p4.z, // z
segint ? keys.SEGINT : rayint ? keys.RAYINT : keys.PROJECT
);
ip.dist = a;
ip.p1 = p3;
ip.p2 = p4;
return ip;
}
/**
* used by {@link rayIntersect} and {@link Polygon.trace}
*/
function intersectRayLine(ro, s1, p1, p2, infinite) {
let keys = base.key,
p1x = ro.x,
p1y = ro.y,
s1x = s1.dx,
s1y = s1.dy,
p3x = p1.x,
p3y = p1.y,
p4x = p2.x,
p4y = p2.y,
s2x = p4x - p3x,
s2y = p4y - p3y,
d = (s2y * s1x) - (s2x * s1y);
let a = p1y - p3y,
b = p1x - p3x,
n1 = (s2x * a) - (s2y * b),
n2 = (s1x * a) - (s1y * b);
if (Math.abs(d) < 0.000000000001) {
// lines are parallel or collinear
return null;
}
a = n1 / d;
b = n2 / d;
if (infinite || (inCloseRange(b, 0, 1) && a >= 0)) {
let ip = newPoint(
p1x + (a * s1x),
p1y + (a * s1y),
p2.z || ro.z,
keys.NONE
);
ip.dist = a;
ip.p1 = p1;
ip.p2 = p2;
return ip;
}
return null;
}
/**
* @param {Point} p1
* @param {Point} p2
* @param {Point} p3
* @param {Point} p4
* @returns {number}
*/
function determinant(p1, p2, p3, p4) {
let d1x = (p2.x - p1.x),
d1y = (p2.y - p1.y),
d2x = (p4.x - p3.x),
d2y = (p4.y - p3.y);
return (d2y * d1x) - (d2x * d1y);
}
/**
* Find Z of XY pair given plane defined by 3 points
*/
function zInPlane(p1, p2, p3, x, y) {
let vec1 = new THREE.Vector3(p2.x - p1.x, p2.y - p1.y, p2.z - p1.z);
let vec2 = new THREE.Vector3(p3.x - p1.x, p3.y - p1.y, p3.z - p1.z);
vec1.cross(vec2);
if (vec1.z !== 0) {
return ((vec1.x * (x - p1.x) + vec1.y * (y - p1.y)) / -vec1.z) + p1.z;
}
}
/**
* find circle center given 3 points in XY plane
*/
function circleCenter(A, B, C) {
let denominator = 2 * determinant33([
[A.x, A.y, 1],
[B.x, B.y, 1],
[C.x, C.y, 1]
]);
let xmat = [
[A.x * A.x + A.y * A.y, A.y, 1],
[B.x * B.x + B.y * B.y, B.y, 1],
[C.x * C.x + C.y * C.y, C.y, 1]
];
let ymat = [
[A.x, A.x * A.x + A.y * A.y, 1],
[B.x, B.x * B.x + B.y * B.y, 1],
[C.x, C.x * C.x + C.y * C.y, 1]
];
let center = {
x: determinant33(xmat) / denominator,
y: determinant33(ymat) / denominator,
z: A.z
};
if (denominator !== 0) {
return center;
}
}
/**
* find the determinant of a 3x3 matrix array organized [row, col]
*/
function determinant33(mat33) {
let cofactor00 = mat33[0][0] * (mat33[1][1] * mat33[2][2] - mat33[1][2] * mat33[2][1]);
let cofactor01 = -mat33[0][1] * (mat33[1][0] * mat33[2][2] - mat33[1][2] * mat33[2][0]);
let cofactor02 = mat33[0][2] * (mat33[1][0] * mat33[2][1] - mat33[1][1] * mat33[2][0]);
return cofactor00 + cofactor01 + cofactor02;
}
/**
* return circle center given three points
* from https://stackoverflow.com/questions/4103405/what-is-the-algorithm-for-finding-the-center-of-a-circle-from-three-points
*/
function center2d(A, B, C, rad) {
let center = circleCenter(A, B, C);
if (center && rad) {
let dx = center.x - A.x;
let dy = center.y - A.y;
center.r = Math.sqrt(dx * dx + dy * dy)
}
return center;
}
/**
* return one of two possible circle centers given two points, a radius and clock direction
* https://stackoverflow.com/questions/36211171/finding-center-of-a-circle-given-two-points-and-radius
*/
function center2pr(p1, p2, r, clockwise) {
let x1 = p1.x,
x2 = p2.x,
y1 = p1.y,
y2 = p2.y,
q = Math.sqrt(Math.pow((x2 - x1), 2) + Math.pow((y2 - y1), 2)),
y3 = (y1 + y2) / 2,
x3 = (x1 + x2) / 2,
basex = Math.sqrt(Math.pow(r, 2) - Math.pow((q / 2), 2)) * (y1 - y2) / q, //calculate once
basey = Math.sqrt(Math.pow(r, 2) - Math.pow((q / 2), 2)) * (x2 - x1) / q, //calculate once
centerx1 = x3 + basex, //center x of circle 1
centery1 = y3 + basey, //center y of circle 1
centerx2 = x3 - basex, //center x of circle 2
centery2 = y3 - basey, //center y of circle 2
dir = new THREE.Vector2(x2 - x1, y2 - y1),
vec1 = new THREE.Vector2(centerx1 - x1, centery1 - y1),
vec2 = new THREE.Vector2(centerx2 - x1, centery2 - x1);
if (clockwise) {
return dir.cross(vec1) > 0 ? {
x: centerx1,
y: centery1
} : {
x: centerx2,
y: centery2
};
} else {
return dir.cross(vec1) < 0 ? {
x: centerx1,
y: centery1
} : {
x: centerx2,
y: centery2
};
}
}
// find angle difference between 0 and 2pi from n1 to n2 (signed depending on clock direction)
function thetaDiff(n1, n2, clockwise) {
let diff = n2 - n1;
while (diff < -Math.PI) diff += Math.PI * 2;
while (diff > Math.PI) diff -= Math.PI * 2;
if (clockwise && diff > 0) diff -= Math.PI * 2;
if (!clockwise && diff < 0) diff += Math.PI * 2;
return diff;
}
// order array of elements using next closest element comparator
// used for things like next-closest point walks (fdm thin fill)
// in future, replace poly2poly and similar with this
function orderClosest(array, fn, from) {
if (!array.length) {
return array;
}
let out = new Array(array.length);
let outi = 0;
let root = 0;
if (!from) {
from = array[root++];
out[outi++] = from;
}
for (;;) {
let best, best_i, best_dist = Infinity;
for (let i = root; i < array.length; i++) {
let el = array[i];
if (!el) continue;
let dist = fn(from, el);
if (dist < best_dist) {
best_dist = dist;
best_i = i;
best = el;
}
}
if (!best) break;
array[best_i] = undefined;
from = out[outi++] = best;
}
return out;
}
// wrapper for earcut that handles higher order dimensions and finds the
// two with the greatest delta to pass to the earcut algorith, then returns
// an unwrapped array in the original dimensions
// at present, only used by load.obj.parse()
function triangulate(array, holes, dims, pos) {
let narray;
let info;
if (dims === 2) {
narray = array;
} else {
let min = new Array(dims).fill(Infinity);
let max = new Array(dims).fill(-Infinity);
for (let i = 0, l = array.length; i < l;) {
for (let j = 0, av; j < dims; j++) {
av = array[i++];
min[j] = Math.min(min[j], av);
max[j] = Math.max(max[j], av);
}
}
let delta = new Array(dims);
for (let i = 0; i < dims; i++) {
delta[i] = max[i] - min[i];
}
let dmax = 0,
d1, d2;
for (let i = 0; i < dims; i++) {
if (delta[i] > dmax) {
dmax = delta[i];
d1 = i;
}
}
info = { d1, d2, dmax, delta: delta.slice() };
delta[d1] = dmax = 0;
for (let i = 0; i < dims; i++) {
if (delta[i] > dmax) {
dmax = delta[i];
d2 = i;
}
}
narray = new Array((array.length / dims) * 2);
for (let i = 0, j = 0; i < array.length; i += dims) {
narray[j++] = array[i + d1];
narray[j++] = array[i + d2];
}
}
let ec = earcut(narray, holes, 2);
if (pos) {
return ec;
}
let oa = new Array(ec.length * dims);
for (let i = 0, e = 0, l = ec.length, ai; i < l; i++) {
ai = ec[i] * dims;
for (let j = 0; j < dims; j++) {
oa[e++] = array[ai + j];
}
}
if (oa.length === 0) {
console.log('debug_triangulate', {array, oa, info});
}
return oa;
}
function flatten(arr) {
return arr.reduce((acc, val) => Array.isArray(val) ? acc.concat(flatten(val)) : acc.concat(val), [])
}
function comma(v) {
if (!v) return v;
let [lt, rt] = v.toString().split('.');
lt = lt.toString().split('').reverse().map((v, i, a) => {
return (i < a.length - 1 && i % 3 === 2) ? `,${v}` : v
}).reverse().join('');
return rt ? `${lt}.${rt}` : lt;
}
// Create the base namespace object
const base = {
config: {
// size of gcode debug arrow head
debug_arrow: 0.25,
// default # of decimal places in generated gcode
gcode_decimals: 4,
// heal disjoint polygons in slicing (experimental)
bridgeLineGapDistance: 0.05,
bridgeLineGapDistanceMax: 25,
// Bounds default margin nearTo
// Polygon.offset mindist2 offset precision
precision_offset: 0.05,
// Polygon.isEquivalent area() isCloseTo
precision_poly_area: 0.05,
// Polygon.isEquivalent bounds() equals value
precision_poly_bounds: 0.01,
// Polygon.isEquivalent point distance to other poly line
precision_poly_merge: 0.05,
// Polygon.traceIntersects mindist2
// Polygon.overlaps (bounds overlaps test precision)
// Polygon.isEquivalent circularity (is circle if 1-this < merge)
// Slope.isSame (vert/horiz w/in this value)
// isCloseTo() default for dist
// sliceIntersects point merge dist for non-fill
precision_merge: 0.005,
precision_slice_z: 0.0001,
// Point.isInPolygon nearPolygon value
// Point.isInPolygonNotNear nearPolygon value
// Point.isMergable2D distToSq2D value
// Point.isMergable3D distToSq2D value
// Polygon.isInside nearPolygon value
// Polygon.isOutside nearPolygon value
precision_merge_sq: sqr(0.005),
// Bound.isNested inflation value for potential parent
precision_bounds: 0.0001,
// Slope.isSame default precision
precision_slope: 0.02,
// Slope.isSame use to calculate precision
precision_slope_merge: 0.25,
// sliceIntersect point merge distance for fill
precision_fill_merge: 0.001,
// convertPoints point merge distance
// other values break cube-s9 (wtf)
precision_decimate: 0.05,
// decimate test over this many points
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
precision_circularity: 0.001,
// polygon fill hinting (settings override)
hint_len_min: sqr(3),
hint_len_max: sqr(20),
hint_min_circ: 0.15,
// tolerances to determine if a point is near a masking polygon
precision_mask_tolerance: 0.001,
// Polygon isInside,isOutside tolerance (accounts for midpoint skew)
precision_close_to_poly_sq: sqr(0.001),
// how long a segment has to be to trigger a midpoint check (inner/outer)
precision_midpoint_check_dist: 1,
precision_nested_sq: sqr(0.01),
// clipper multiplier
clipper: 100000,
// clipper poly clean
clipperClean: 250
},
key: {
NONE: "",
PROJECT: "project",
SEGINT: "segint",
RAYINT: "rayint",
PARALLEL: "parallel"
},
util: {
time,
lerp,
pwait,
ptimer,
numOrDefault,
doCombinations,
isClockwise,
isCounterClockwise,
pac,
area2,
isCloseTo,
inCloseRange,
sqr,
rotate,
toRadians,
toDegrees,
dist2D,
distSq,
distSqv2,
offsetPrecision,
inRange,
round,
clamp,
intersect,
intersectRayLine,
determinant,
zInPlane,
circleCenter,
determinant33,
center2d,
center2pr,
thetaDiff,
orderClosest,
triangulate,
flatten,
comma
},
paths: {
pointsToPath: function(points, offset, open, miter = 1.5) {
const absoff = Math.abs(offset);
// calculate segment normals which are used to calculate vertex normals
// next segment info is associated with the current point
const nupoints = [];
const length = points.length;
if (length === 2 && points[0].isEqual(points[1])) {
return { };
}
const dedup = (open && length > 2) || (!open && length > 3);
for (let i=0; i<length; i++) {
let p1 = points[i];
let p2 = points[(i + 1) % length];
p1.normal = calc_normal(p1, p2);
// drop next duplicate point if segment length is 0
// is possible there are 3 dups in a row and this is
// not handled. could make if a while, but that could
// end up in a loop without additional checks. ignore
// the case when the last and first point are the same
// which is valid when the line is an open path
if (dedup && p1.normal.len === 0 && i !== length - 1) {
p1.normal = calc_normal(p1, points[(i + 2) % length]);
i++;
}
nupoints.push(p1);
}
if (nupoints.length === 1) {
console.log({points, nupoints});
}
// when points are dropped, we need the new array
points = nupoints;
// generate left / right paths and triangle faces
const left = [];
const right = [];
const faces = [];
const normals = [];
const zn = -1;
// calculate vertex normals from segments normals
// vertex info is associated with the origin point
let fl, fr;
for (let i=0, l=points.length; i<l; i++) {
let n1 = points[(i+l-1)%l].normal;
let n2 = points[(i+l)%l].normal;
let vn = open && (i === 0 || i === l-1) ?
end_vertex(n1, n2, offset, i === 0) :
calc_vertex(n1, n2, offset);
let { p1, p2 } = n2;
let { io, vl } = vn;
if (offset < 0) {
io = -io;
}
let split_left = false, split_right = false;
if (io > 0) { // right
split_left = vl > absoff * miter;
split_right = vl > Math.min(n1.len, n2.len) + absoff;
} else { // left
split_right = vl > absoff * miter;
split_left = vl > Math.min(n1.len, n2.len) + absoff;
}
let l0 = left.peek(1);
let r0 = right.peek(1);
if (split_left || split_right) {
// shorten each leg and insert new point
let delta = 0.1;
let np1 = p1.clone().move({ x: n1.dy * delta, y: -n1.dx * delta, z: 0 });
let np2 = p1.clone().move({ x:-n2.dy * delta, y: n2.dx * delta, z: 0 });
let sn1 = np1.normal = calc_normal(np1, np2);
let sn2 = np2.normal = p1.normal;
let nv1 = calc_vertex(n1.p1.normal, sn1, offset, np1);
let nv2 = calc_vertex(sn1, sn2, offset, np2);
if (split_right) {
right.push(v2pr(nv1), v2pr(nv2));
} else {
right.push(v2pr(vn));
}
if (split_left) {
left.push(v2pl(nv1), v2pl(nv2));
} else {
left.push(v2pl(vn));
}
if (faces) {
let l1 = left.peek(1);
let r1 = right.peek(1);
let l2 = left.peek(2);
let r2 = right.peek(2);
faces.push([l0, r0, l1], [r0, r1, l1], [l1, r1, l2], [r1, r2, l2]);
}
} else {
if (split_right) {
right.push(v2pr(vn));
} else {
right.push(v2pr(vn));
}
if (split_left) {
left.push(v2pl(vn));
} else {
left.push(v2pl(vn));
}
if (faces) {
let l1 = left.peek(1);
let r1 = right.peek(1);
faces.push([l0, r0, l1], [r0, r1, l1]);
}
}
}
return { left, right, faces, normals };
},
pathTo3D: function(path, height, z) {
const { left, right, faces } = path;
const vertices = [];
const normals = [];
const uvs = [];
const indices = [];
let i, j, k, l, r, f, v, n, u;
// add left and right vertices
for (i = 0; i < left.length; i++) {
l = left[i];
r = right[i];
v = [l.x, l.y, z, r.x, r.y, z];
n = [0, 0, 1, 0, 0, 1];
u = [0, 0, 1, 0];
vertices.push(...v);
normals.push(...n);
uvs.push(...u);
}
// add top vertices
for (i = 0; i < left.length; i++) {
l = left[i];
r = right[i];
v = [l.x, l.y, z + height, r.x, r.y, z + height];
n = [0, 0, 1, 0, 0, 1];
u = [0, 1, 1, 1];
vertices.push(...v);
normals.push(...n);
uvs.push(...u);
}
// add faces
for (i = 0; i < faces.length; i++) {
f = faces[i];
indices.push(f[0], f[1], f[2]);
}
// add top faces
for (i = 0; i < faces.length; i++) {
f = faces[i];
indices.push(f[0] + left.length, f[1] + left.length, f[2] + left.length);
}
// add side faces
for (i = 0; i < left.length - 1; i++) {
j = i * 2;
k = j + 2;
indices.push(j, k, j + 1, j + 1, k, k + 1);
}
return { vertices, normals, uvs, indices };
}
},
newPoint
};
// Helper functions for paths
function calc_normal(p1, p2) {
let dx = p2.x - p1.x;
let dy = p2.y - p1.y;
let len = Math.sqrt(dx * dx + dy * dy);
let mn = (1 / len);
dx *= mn;
dy *= mn;
return({ dx: dy, dy: -dx, p1, p2, len });
}
function end_vertex(n1, n2, off, start) {
let dx, dy;
if (start) {
dx = n2.dx * off;
dy = n2.dy * off;
} else {
dx = n1.dx * off;
dy = n1.dy * off;
}
return { dx, dy, vp: n1.p2 };
}
function calc_vertex(n1, n2, off, vp) {
let dx, dy, io, vl, q, r;
r = 1 + (n1.dx * n2.dx + n1.dy * n2.dy);
q = off / r;
// handle spurs that switch back 180 degrees
if (q === Infinity) {
q = 0;
}
dx = (n1.dx + n2.dx) * q;
dy = (n1.dy + n2.dy) * q;
// io tells us whether we're turning left or right
io = (n1.dx * n2.dy - n2.dx * n1.dy);
// vertex length can be compared to the previons and next
// segment lengths to see if we're highly acute
vl = Math.sqrt(dx * dx + dy * dy);
return { dx, dy, vp: vp || n1.p2, io, vl };
}
function v2pl(rec) {
let p = rec.vp.clone();
p.x += rec.dx;
p.y += rec.dy;
p.vp = rec.vp;
return p;
}
function v2pr(rec) {
let p = rec.vp.clone();
p.x -= rec.dx;
p.y -= rec.dy;
p.vp = rec.vp;
return p;
}
const earcut = window.earcut;
// Export the base object and its properties
export { base };
export { earcut };
export const { config, key, util, paths } = base;
export { newPoint };

133
src2/geo/bounds.js Normal file
View file

@ -0,0 +1,133 @@
/** Copyright 2014-2019 Stewart Allen -- All Rights Reserved */
import { config, util } from './base.js';
import { newPoint } from './point.js';
export class Bounds {
constructor() {
this.minx = 10e7;
this.miny = 10e7;
this.maxx = -10e7;
this.maxy = -10e7;
}
set(minx, maxx, miny, maxy) {
this.minx = minx;
this.miny = miny;
this.maxx = maxx;
this.maxy = maxy;
return this;
}
clone() {
let b = new Bounds();
b.minx = this.minx;
b.miny = this.miny;
b.maxx = this.maxx;
b.maxy = this.maxy;
b.maxy = this.maxy;
return b;
}
equals(bounds, margin) {
if (!margin) margin = config.precision_offset;
return util.isCloseTo(this.minx, bounds.minx, margin) &&
util.isCloseTo(this.miny, bounds.miny, margin) &&
util.isCloseTo(this.maxx, bounds.maxx, margin) &&
util.isCloseTo(this.maxy, bounds.maxy, margin);
}
/**
* @returns {Number} absolute delta in x,y coordinate space
*/
delta(bounds) {
return 0 +
Math.abs(this.minx - bounds.minx) +
Math.abs(this.miny - bounds.miny) +
Math.abs(this.maxx - bounds.maxx) +
Math.abs(this.maxy - bounds.maxy);
}
/**
* @param {Bounds} b
*/
merge(b) {
this.minx = Math.min(this.minx, b.minx);
this.maxx = Math.max(this.maxx, b.maxx);
this.miny = Math.min(this.miny, b.miny);
this.maxy = Math.max(this.maxy, b.maxy);
return this;
}
/**
* @param {Point} p
*/
update(p) {
this.minx = Math.min(this.minx, p.x);
this.maxx = Math.max(this.maxx, p.x);
this.miny = Math.min(this.miny, p.y);
this.maxy = Math.max(this.maxy, p.y);
return this;
}
contains(bounds) {
return bounds.isNested(this);
}
containsXY(x, y) {
return x >= this.minx && x <= this.maxx && y >= this.miny && y <= this.maxy;
}
containsOffsetXY(x, y, offset) {
return x >= this.minx - offset && x <= this.maxx + offset && y >= this.miny - offset && y <= this.maxy + offset;
}
/**
* @param {Bounds} parent
* @returns {boolean} true if fully inside parent bounds
*/
isNested(parent, precision = config.precision_bounds) {
return (
this.minx >= parent.minx - precision && // min-x
this.maxx <= parent.maxx + precision && // max-x
this.miny >= parent.miny - precision && // min-y
this.maxy <= parent.maxy + precision // max-y
);
}
/**
* @param {Bounds} b
* @param {number} precision
* @returns {boolean}
*/
overlaps(b, precision = config.precision_bounds) {
return (
Math.abs(this.centerx() - b.centerx()) * 2 - precision < this.width() + b.width() &&
Math.abs(this.centery() - b.centery()) * 2 - precision < this.height() + b.height()
);
}
width() {
return this.maxx - this.minx;
}
height() {
return this.maxy - this.miny;
}
center(z = 0) {
return newPoint(this.centerx(), this.centery(), z);
}
centerx() {
return this.minx + this.width() / 2;
}
centery() {
return this.miny + this.height() / 2;
}
}
export function newBounds() {
return new Bounds();
}

132
src2/geo/csg.js Normal file
View file

@ -0,0 +1,132 @@
/** Copyright 2014-2019 Stewart Allen -- All Rights Reserved */
"use strict";
import { base } from './base.js';
import { THREE } from '../ext/three.js';
import manifold from '../ext/manifold.js';
const { Mesh, BufferGeometry, Vector3 } = THREE;
const debug = true;
const precision = 0.001;
const factor = 1/precision;
function log() {
if (root?.mesh?.log) {
root.mesh.log(...arguments)
} else if (root.debug === true) {
console.log(...arguments);
}
}
export const CSG = {
// accepts 2 or more arguments with threejs vertex position arrays
union() {
return CSG.moduleOp('manifold.union', 'union', ...arguments);
},
// accepts 2 or more arguments with threejs vertex position arrays
// the fist argument is the target mesh. the rest are negatives
subtract() {
return CSG.moduleOp('manifold.subtract', 'difference', ...arguments);
},
// accepts 2 or more arguments with threejs vertex position arrays
intersect() {
return CSG.moduleOp('manifold.intersect', 'intersection', ...arguments);
},
// accepts 2 or more arguments with threejs vertex position arrays
difference() {
return CSG.moduleOp('manifold.difference', 'difference', ...arguments);
},
moduleOp(name, op) {
log(`${name} ${arguments.length} meshes`);
const args = [...arguments].slice(2).map(a => new Instance.Manifold(a));
if (args.length < 2) {
throw "missing one or more meshes";
}
const result = Instance.Manifold[op](...args);
const output = result.getMesh();
const errors = [ ...args, result ].map(m => m.status().value);
for (let m of [ ...args, result ]) {
m.delete();
}
if (errors.reduce((a,b) => a+b)) {
throw `${errors}`;
}
return output;
},
toPositionArray(mesh) {
let { vertProperties, triVerts } = mesh;
let vertPos = vertProperties;
if (vertPos && triVerts) {
const vertices = new Float32Array(triVerts.length * 3);
for (let p=0, i=0, l=triVerts.length; i<l; i++) {
let vi = triVerts[i] * 3;
vertices[p++] = vertPos[vi++];
vertices[p++] = vertPos[vi++];
vertices[p++] = vertPos[vi++];
}
return vertices.map(v => v/factor);
}
throw "mesh missing required fields";
},
fromPositionArray(pos) {
const { index, vertices } = indexVertices(pos);
let mesh = new Instance.Mesh({
vertProperties: vertices.map(v => (v*factor)|0).toFloat32(),
triVerts: index.toUint32()
});
return mesh;
},
toBox3(mesh) {
const { min, max } = mesh._boundingBox();
const vmin = new THREE.Vector3(min.x, min.y, min.z);
const vmax = new THREE.Vector3(max.x, max.y, max.z);
return new THREE.Box3(vmin, vmax);
},
indexVertices
};
function indexVertices(pos) {
log(`indexing ${pos.length/3} vertices`);
let ipos = 0;
const index = [];
const vertices = [];
const cache = {};
const temp = { x: 0, y: 0, z: 0 };
for (let i=0, length = pos.length; i<length; ) {
temp.x = pos[i++];
temp.y = pos[i++];
temp.z = pos[i++];
let key = [
((temp.x * 100000) | 0),
((temp.y * 100000) | 0),
((temp.z * 100000) | 0)
].join('');
let ip = cache[key];
if (ip >= 0) {
index.push(ip);
} else {
index.push(ipos);
cache[key] = (ipos++);
vertices.push(temp.x, temp.y, temp.z);
}
}
return { index, vertices };
}
let Instance;
manifold({
locateFile() { return "/wasm/manifold.wasm" }
}).then(inst => {
inst.setup();
Instance = inst;
CSG.Instance = () => { return Instance };
});

209
src2/geo/gyroid.js Normal file
View file

@ -0,0 +1,209 @@
/** Copyright Stewart Allen -- All Rights Reserved */
const PI2 = Math.PI * 2;
let cache = {};
let lastVal;
let lastRes = 0;
let lastSlice = 0;
/**
* @param off {number} z offset value from 0-1
* @param res {number} resolution (pixels/slices per side)
*/
export function slice(off, res, val) {
// auto clear cach if it hasn't been hit in the last 20 seconds
// or the requested resolution or tip values have changed
let now = Date.now();
if (res !== lastRes || val !== lastVal || now - lastSlice > 20000) {
cache = {};
}
lastVal = val;
lastRes = res;
lastSlice = now;
let rez = parseInt(res || 200);
let inc = PI2 / rez;
let z = PI2 * off;
let key = (z % PI2).round(3);
let hit = cache[key];
if (hit) {
return hit;
}
let tip = val || 0;
let edge = [];
let vals = [];
let points = 0;
let points_lr = 0;
let points_td = 0;
for (let x=0; x<PI2; x += inc) {
let vrow = []; // raw values row
let erow = []; // edge values row
edge.push(erow);
vals.push(vrow);
for (let y=0; y<PI2; y += inc) {
erow.push(0);
vrow.push(
Math.sin(x) * Math.cos(y) +
Math.sin(y) * Math.cos(z) +
Math.sin(z) * Math.cos(x)
);
}
}
// left-right threshold search (red)
vals.forEach((vrow, y) => {
let erow = edge[y];
let lval = vrow[vrow.length - 1];
vrow.forEach((val, x) => {
if (
(lval <= tip && val >= tip) || (lval >= tip && val <= tip) ||
(lval <= -tip && val >= -tip) || (lval >= -tip && val <= -tip)
) {
erow[x] = 1;
points++;
points_lr++;
}
lval = val;
})
});
// top-down threshold search (green)
for (let x=0; x<rez; x++) {
let lval = vals[vals.length-1][x];
for (let y=0; y<rez; y++) {
let val = vals[y][x];
if (
(lval <= tip && val >= tip) || (lval >= tip && val <= tip) ||
(lval <= -tip && val >= -tip) || (lval >= -tip && val <= -tip)
) {
if (edge[y][x]) {
edge[y][x] = 3;
} else {
edge[y][x] = 2;
points++
}
points_td++;
}
lval = val;
}
}
// deterime prevailing direction for chaining
let dir = points_td > points_lr ? 'lr' : 'td';
// create sparse representation
let sparse = [];
let center = rez / 2;
edge.forEach((row,y) => {
row.forEach((val,x) => {
if (val) {
let dx = Math.abs(x - center);
let dy = Math.abs(y - center);
sparse.push({x: x/rez, y: y/rez, val, dist: Math.max(dx,dy)});
}
});
});
sparse.sort((a,b) => {
return b.dist - a.dist;
});
// join sparse points array by closest distance
let polys = [];
let chain;
let added;
let cleared = 0;
let maxdist = 0.05;
do {
chain = null;
for (let i=0; i<sparse.length; i++) {
if (sparse[i]) {
chain = [ sparse[i] ];
polys.push(chain);
sparse[i] = null;
cleared++;
break;
}
}
do {
added = false;
let target = chain[chain.length - 1];
let cl_elm = null;
let cl_idx = null;
let cl_dst = Infinity;
for (let i=0; i<sparse.length; i++) {
let test_el = sparse[i];
if (test_el) {
let dst = distTo(target, test_el, dir);
if (cl_idx === null || dst < cl_dst) {
cl_idx = i;
cl_elm = test_el;
cl_dst = dst;
}
}
}
if (cl_elm) {
if (cl_dst > maxdist) {
break;
}
sparse[cl_idx] = null;
cleared++;
chain.push(cl_elm);
added = true;
}
} while (added);
} while (cleared < sparse.length);
let psimple = polys
.map(poly => filter(poly, 0))
.map(poly => filter(poly, inc));
let slice = {edge, points, dir, polys: psimple};
cache[key] = slice;
return slice;
}
// merge co-linear and distance threshold
export function filter(poly, inc) {
if (poly.length <= 2) {
return poly;
}
let nupoly = [ poly[0] ];
let e1 = poly[1];
let e2 = null;
let last = poly.length - 2;
for (let i=1; i<poly.length; i++) {
let el = poly[i];
let drop = inc ?
(distTo(e1, el) <= inc) :
(e1.x === el.x || e1.y === el.y);
if (drop) {
e2 = el;
if (i < last) {
continue;
}
}
if (e2) {
nupoly.push({x:(e1.x + e2.x)/2, y:(e1.y + e2.y)/2});
e2 = null;
} else {
nupoly.push(e1);
if (i === last) {
nupoly.push(el);
}
}
e1 = el;
}
return nupoly;
}
export function distTo(a, b, dir) {
let dx = a.x - b.x;
let dy = a.y - b.y;
if (dir === 'lr') {
return Math.abs(dx);
} else if (dir === 'td') {
return Math.abs(dy);
}
return Math.sqrt(dx * dx + dy * dy);
}

56
src2/geo/line.js Normal file
View file

@ -0,0 +1,56 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
export class Line {
constructor(p1, p2, key) {
if (!key) key = [p1.key, p2.key].join(';');
this.p1 = p1;
this.p2 = p2;
this.key = key;
}
length() {
return Math.sqrt(this.length2());
}
/**
* @returns {number} square of length
*/
length2() {
return this.p1.distToSq2D(this.p2);
}
slope() {
return base.newSlope(this.p1.slopeTo(this.p2));
}
reverse() {
let t = this.p1;
this.p1 = this.p2;
this.p2 = t;
return this;
}
midpoint() {
return this.p1.midPointTo(this.p2);
}
isCollinear(line) {
let p1 = this.p1,
p2 = this.p2,
p3 = line.p1,
p4 = line.p2,
d1x = (p2.x - p1.x),
d1y = (p2.y - p1.y),
d2x = (p4.x - p3.x),
d2y = (p4.y - p3.y);
return Math.abs( (d2y * d1x) - (d2x * d1y) ) < 0.0001;
}
}
export function newLine(p1, p2, key) {
return new Line(p1, p2, key);
}
export function newOrderedLine(p1, p2, key) {
return p1.key < p2.key ? newLine(p1,p2,key) : newLine(p2,p1,key);
}

553
src2/geo/paths.js Normal file
View file

@ -0,0 +1,553 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { base } from './base.js';
const { util, config } = base;
const { sqr, numOrDefault } = util;
const DEG2RAD = Math.PI / 180;
/**
* emit each element in an array based on
* the next closest endpoint. arrays contain
* elements with { first, last } points and
* may be open polys, unlike poly2polyEmit
*/
export function tip2tipEmit(array, startPoint, emitter) {
let mindist, dist, found, count = 0;
for (;;) {
found = null;
mindist = Infinity;
array.forEach(function(el) {
if (el.delete) return;
dist = startPoint.distTo2D(el.first);
if (dist < mindist) {
found = {el:el, first:el.first, last:el.last};
mindist = dist;
}
dist = startPoint.distTo2D(el.last);
if (dist < mindist) {
found = {el:el, first:el.last, last:el.first};
mindist = dist;
}
});
if (found) {
found.el.delete = true;
startPoint = found.last;
emitter(found.el, found.first, ++count);
} else {
break;
}
}
return startPoint;
}
/**
* like tip2tipEmit but accepts an array of polygons and the next closest
* point can be anywhere in the adjacent polygon. should be re-written
* to be more like outputOrderClosest() and have the option to account for
* depth in determining distance
*/
export function poly2polyEmit(array, startPoint, emitter, opt = {}) {
let marker = opt.mark || 'delete';
let mindist, dist, found, count = 0;
for (;;) {
found = null;
mindist = Infinity;
for (let poly of array) {
if (poly[marker]) {
continue;
}
if (poly.isOpen()) {
const d2f = startPoint.distTo2D(poly.first());
const d2l = startPoint.distTo2D(poly.last());
if (d2f > mindist && d2l > mindist) {
continue;
}
if (d2l < mindist && d2l < d2f && opt.swapdir !== false) {
poly.reverse();
found = {poly:poly, index:0, point:poly.first()};
mindist = d2l;
} else if (d2f < mindist) {
found = {poly:poly, index:0, point:poly.first()};
mindist = d2f;
}
continue;
}
let area = poly.open ? 1 : poly.area();
poly.forEachPoint(function(point, index) {
dist = opt.weight ?
startPoint.distTo3D(point) * area * area :
startPoint.distTo2D(point);
if (dist < mindist) {
found = {poly:poly, index:index, point:point};
mindist = dist;
}
});
}
if (!found || opt.term) {
break;
}
found.poly[marker] = true;
startPoint = emitter(found.poly, found.index, ++count, startPoint) || found.point;
}
// undo delete marks
if (opt.perm !== true) {
array.forEach(function(poly) { poly[marker] = false });
}
return startPoint;
}
export function calc_normal(p1, p2) {
let dx = p2.x - p1.x;
let dy = p2.y - p1.y;
let len = Math.sqrt(dx * dx + dy * dy);
let mn = (1 / len);
dx *= mn;
dy *= mn;
return({ dx: dy, dy: -dx, p1, p2, len });
}
export function end_vertex(n1, n2, off, start) {
let dx, dy;
if (start) {
dx = n2.dx * off;
dy = n2.dy * off;
} else {
dx = n1.dx * off;
dy = n1.dy * off;
}
return { dx, dy, vp: n1.p2 };
}
export function calc_vertex(n1, n2, off, vp) {
let dx, dy, io, vl, q, r;
r = 1 + (n1.dx * n2.dx + n1.dy * n2.dy);
q = off / r;
// handle spurs that switch back 180 degrees
if (q === Infinity) {
q = 0;
}
dx = (n1.dx + n2.dx) * q;
dy = (n1.dy + n2.dy) * q;
// io tells us whether we're turning left or right
io = (n1.dx * n2.dy - n2.dx * n1.dy);
// vertex length can be compared to the previons and next
// segment lengths to see if we're highly acute
vl = Math.sqrt(dx * dx + dy * dy);
return { dx, dy, vp: vp || n1.p2, io, vl };
}
export function v2pl(rec) {
let p = rec.vp.clone();
p.x += rec.dx;
p.y += rec.dy;
p.vp = rec.vp;
return p;
}
export function v2pr(rec) {
let p = rec.vp.clone();
p.x -= rec.dx;
p.y -= rec.dy;
p.vp = rec.vp;
return p;
}
export function pointsToPath(points, offset, open, miter = 1.5) {
const absoff = Math.abs(offset);
// calculate segment normals which are used to calculate vertex normals
// next segment info is associated with the current point
const nupoints = [];
const length = points.length;
if (length === 2 && points[0].isEqual(points[1])) {
return { };
}
const dedup = (open && length > 2) || (!open && length > 3);
for (let i=0; i<length; i++) {
let p1 = points[i];
let p2 = points[(i + 1) % length];
p1.normal = calc_normal(p1, p2);
// drop next duplicate point if segment length is 0
// is possible there are 3 dups in a row and this is
// not handled. could make if a while, but that could
// end up in a loop without additional checks. ignore
// the case when the last and first point are the same
// which is valid when the line is an open path
if (dedup && p1.normal.len === 0 && i !== length - 1) {
p1.normal = calc_normal(p1, points[(i + 2) % length]);
i++;
}
nupoints.push(p1);
}
if (nupoints.length === 1) {
console.log({points, nupoints});
}
// when points are dropped, we need the new array
points = nupoints;
// generate left / right paths and triangle faces
const left = [];
const right = [];
const faces = [];
const normals = [];
const zn = -1;
// calculate vertex normals from segments normals
// vertex info is associated with the origin point
let fl, fr;
for (let i=0, l=points.length; i<l; i++) {
let n1 = points[(i+l-1)%l].normal;
let n2 = points[(i+l)%l].normal;
let vn = open && (i === 0 || i === l-1) ?
end_vertex(n1, n2, offset, i === 0) :
calc_vertex(n1, n2, offset);
let { p1, p2 } = n2;
let { io, vl } = vn;
if (offset < 0) {
io = -io;
}
let split_left = false, split_right = false;
if (io > 0) { // right
split_left = vl > absoff * miter;
split_right = vl > Math.min(n1.len, n2.len) + absoff;
} else { // left
split_right = vl > absoff * miter;
split_left = vl > Math.min(n1.len, n2.len) + absoff;
}
let l0 = left.peek(1);
let r0 = right.peek(1);
if (split_left || split_right) {
// shorten each leg and insert new point
let delta = 0.1;
let np1 = p1.clone().move({ x: n1.dy * delta, y: -n1.dx * delta, z: 0 });
let np2 = p1.clone().move({ x:-n2.dy * delta, y: n2.dx * delta, z: 0 });
let sn1 = np1.normal = calc_normal(np1, np2);
let sn2 = np2.normal = p1.normal;
let nv1 = calc_vertex(n1.p1.normal, sn1, offset, np1);
let nv2 = calc_vertex(sn1, sn2, offset, np2);
if (split_right) {
right.push(v2pr(nv1), v2pr(nv2));
} else {
right.push(v2pr(vn));
}
if (split_left) {
left.push(v2pl(nv1), v2pl(nv2));
} else {
left.push(v2pl(vn));
}
if (faces) {
let l1 = left.peek(1);
let r1 = right.peek(1);
let l2 = left.peek(2);
let r2 = right.peek(2);
let ln = l1.vp.normal;
let rn = r1.vp.normal;
if (split_left && split_right) {
faces.push(l1, l2, r1);
faces.push(r2, r1, l2);
fl = fl || l2;
fr = fr || r2;
normals.push(ln.dx, ln.dy, zn);
normals.push(ln.dx, ln.dy, zn);
normals.push(-rn.dx, -rn.dy, zn);
normals.push(-rn.dx, -rn.dy, zn);
normals.push(-rn.dx, -rn.dy, zn);
normals.push(ln.dx, ln.dy, zn);
} else if (split_left) {
faces.push(l1, l2, r1);
fl = fl || l2;
fr = fr || r1;
normals.push(ln.dx, ln.dy, zn);
normals.push(ln.dx, ln.dy, zn);
normals.push(-rn.dx, -rn.dy, zn);
} else { // split right
faces.push(r2, r1, l1);
fl = fl || l1;
fr = fr || r2;
normals.push(-rn.dx, -rn.dy, zn);
normals.push(-rn.dx, -rn.dy, zn);
normals.push(ln.dx, ln.dy, zn);
}
}
} else {
left.push(v2pl(vn));
right.push(v2pr(vn));
fl = fl || left.peek(1);
fr = fr || right.peek(1);
}
if (faces && l0 && r0) {
let l1 = left.peek(split_left ? 2 : 1);
let r1 = right.peek(split_right ? 2 : 1);
faces.push(l1, l0, r1);
faces.push(r0, r1, l0);
let ln = l0.vp.normal;
let rn = r0.vp.normal;
normals.push(ln.dx, ln.dy, zn);
normals.push(ln.dx, ln.dy, zn);
normals.push(-rn.dx, -rn.dy, zn);
normals.push(-rn.dx, -rn.dy, zn);
normals.push(-rn.dx, -rn.dy, zn);
normals.push(ln.dx, ln.dy, zn);
}
}
if (open) {
// move open ends by offset length
const p0 = points[0].normal;
const l0 = left[0];
const r0 = right[0];
// improve visuals of open but 90 degree overlapping ends
const move = offset * 0.99;
l0.x += p0.dy * move;
l0.y -= p0.dx * move;
r0.x += p0.dy * move;
r0.y -= p0.dx * move;
const pn = points.peek(2).normal;
const ln = left.peek();
const rn = right.peek();
ln.x -= pn.dy * move;
ln.y += pn.dx * move;
rn.x -= pn.dy * move;
rn.y += pn.dx * move;
}
if (!open && faces) {
let l1 = left.peek(1);
let r1 = right.peek(1);
let ln = l1.vp.normal;
let rn = r1.vp.normal;
faces.push(fl, l1, fr);
faces.push(r1, fr, l1);
normals.push(ln.dx, ln.dy, zn);
normals.push(ln.dx, ln.dy, zn);
normals.push(-rn.dx, -rn.dy, zn);
normals.push(-rn.dx, -rn.dy, zn);
normals.push(-rn.dx, -rn.dy, zn);
normals.push(ln.dx, ln.dy, zn);
}
return { left, right, faces, normals, open };
}
export function pathTo3D(path, height, z) {
const { faces, normals, left, right, open } = path;
const out = [];
const nrm = [];
if (!(faces && left && right)) {
return [];
}
if (z !== undefined) {
for (let p of faces) {
p.z = z;
}
}
for (let p of faces) {
out.push(p.x, p.y, p.z - height);
}
for (let p of faces.slice().reverse()) {
out.push(p.x, p.y, p.z + height);
}
nrm.appendAll(normals);
// reverse normals to match faces, but underside so reverse Z as well
for (let i=normals.length-1; i>0; i-=3) {
nrm.push(normals[i-2]);
nrm.push(normals[i-1]);
nrm.push(-normals[i-0]);
}
for (let i=0, l=left.length, tl = open ? l-1 : l; i<tl; i++) {
let p0 = left[i];
let p1 = left[(i+1)%l];
out.push(p0.x, p0.y, p0.z + height);
out.push(p0.x, p0.y, p0.z - height);
out.push(p1.x, p1.y, p1.z - height);
out.push(p1.x, p1.y, p1.z - height);
out.push(p1.x, p1.y, p1.z + height);
out.push(p0.x, p0.y, p0.z + height);
let ln = p0.vp.normal;
nrm.push(ln.dx, ln.dy, -1);
nrm.push(ln.dx, ln.dy, 1);
nrm.push(ln.dx, ln.dy, 1);
nrm.push(ln.dx, ln.dy, 1);
nrm.push(ln.dx, ln.dy, -1);
nrm.push(ln.dx, ln.dy, -1);
}
for (let i=0, l=right.length, tl = open ? l-1 : l; i<tl; i++) {
let p0 = right[i];
let p1 = right[(i+1)%l];
out.push(p0.x, p0.y, p0.z + height);
out.push(p1.x, p1.y, p1.z - height);
out.push(p0.x, p0.y, p0.z - height);
out.push(p1.x, p1.y, p1.z - height);
out.push(p0.x, p0.y, p0.z + height);
out.push(p1.x, p1.y, p1.z + height);
let rn = p0.vp.normal;
nrm.push(-rn.dy, rn.dx, 1);
nrm.push(-rn.dy, rn.dx, -1);
nrm.push(-rn.dy, rn.dx, -1);
nrm.push(-rn.dy, rn.dx, -1);
nrm.push(-rn.dy, rn.dx, 1);
nrm.push(-rn.dy, rn.dx, 1);
}
if (open) {
// begin cap
let l0 = left[0];
let r0 = right[0];
out.push(l0.x, l0.y, l0.z + height);
out.push(r0.x, r0.y, r0.z - height);
out.push(l0.x, l0.y, l0.z - height);
out.push(r0.x, r0.y, r0.z + height);
out.push(r0.x, r0.y, r0.z - height);
out.push(l0.x, l0.y, l0.z + height);
let ln = l0.vp.normal;
nrm.push(-ln.dy, ln.dx, 1);
nrm.push(-ln.dy, ln.dx, -1);
nrm.push(-ln.dy, ln.dx, -1);
nrm.push(-ln.dy, ln.dx, 1);
nrm.push(-ln.dy, ln.dx, -1);
nrm.push(-ln.dy, ln.dx, 1);
// end cap
let le = left.peek();
let re = right.peek();
out.push(le.x, le.y, le.z + height);
out.push(le.x, le.y, le.z - height);
out.push(re.x, re.y, re.z - height);
out.push(re.x, re.y, re.z + height);
out.push(le.x, le.y, le.z + height);
out.push(re.x, re.y, re.z - height);
ln = re.vp.normal;
nrm.push(-ln.dy, ln.dx, 1);
nrm.push(-ln.dy, ln.dx, -1);
nrm.push(-ln.dy, ln.dx, -1);
nrm.push(-ln.dy, ln.dx, 1);
nrm.push(-ln.dy, ln.dx, 1);
nrm.push(-ln.dy, ln.dx, -1);
}
return { faces: out, normals: nrm };
}
// produces indexed geometry which isn't ideal for rendering because
// the default threejs generated vertex normals aren't accurate
export function shapeToPath(shape, points, closed) {
closed = closed !== undefined ? closed : true;
const profileGeometry = new THREE.ShapeGeometry(shape);
profileGeometry.rotateX(Math.PI * .5);
const profile = profileGeometry.attributes.position;
const faces = new Float32Array(profile.count * points.length * 3);
for (let i = 0; i < points.length; i++) {
const v1 = new THREE.Vector2().subVectors(points[i - 1 < 0 ? points.length - 1 : i - 1], points[i]);
const v2 = new THREE.Vector2().subVectors(points[i + 1 == points.length ? 0 : i + 1], points[i]);
const angle = v2.angle() - v1.angle();
const halfAngle = angle * .5;
let hA = halfAngle;
let tA = v2.angle() + Math.PI * .5;
if (!closed){
if (i == 0 || i == points.length - 1) {hA = Math.PI * .5;}
if (i == points.length - 1) {tA = v1.angle() - Math.PI * .5;}
}
const shift = Math.tan(hA - Math.PI * .5);
const shiftMatrix = new THREE.Matrix4().set(
1, 0, 0, 0,
-shift, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1
);
const tempAngle = tA;
const rotationMatrix = new THREE.Matrix4().set(
Math.cos(tempAngle), -Math.sin(tempAngle), 0, 0,
Math.sin(tempAngle), Math.cos(tempAngle), 0, 0,
0, 0, 1, 0,
0, 0, 0, 1
);
const translationMatrix = new THREE.Matrix4().set(
1, 0, 0, points[i].x,
0, 1, 0, points[i].y,
0, 0, 1, 0,
0, 0, 0, 1,
);
const cloneProfile = profile.clone();
cloneProfile.applyMatrix4(shiftMatrix);
cloneProfile.applyMatrix4(rotationMatrix);
cloneProfile.applyMatrix4(translationMatrix);
faces.set(cloneProfile.array, cloneProfile.count * i * 3);
}
const index = [];
const lastCorner = closed == false ? points.length - 1: points.length;
for (let i = 0; i < lastCorner; i++) {
for (let j = 0; j < profile.count; j++) {
const currCorner = i;
const nextCorner = i + 1 == points.length ? 0 : i + 1;
const currPoint = j;
const nextPoint = j + 1 == profile.count ? 0 : j + 1;
const a = nextPoint + profile.count * currCorner;
const b = currPoint + profile.count * currCorner;
const c = currPoint + profile.count * nextCorner;
const d = nextPoint + profile.count * nextCorner;
index.push(a, b, d);
index.push(b, c, d);
}
}
if (!closed) {
// cheating because we know the profile length is 4 (for now)
const p1 = 0 + profile.count * 0;
const p2 = 1 + profile.count * 0;
const p3 = 2 + profile.count * 0;
const p4 = 3 + profile.count * 0;
index.push(p1, p2, p3);
index.push(p1, p3, p4);
const lc = lastCorner;
const p5 = 0 + profile.count * lc;
const p6 = 1 + profile.count * lc;
const p7 = 2 + profile.count * lc;
const p8 = 3 + profile.count * lc;
index.push(p7, p6, p5);
index.push(p8, p7, p5);
}
return {index, faces};
}
export class FloatPacker {
constructor(size, factor) {
this.size = size;
this.factor = Math.min(factor || 1.2, 1.1);
this.array = new Float32Array(size);
this.pos = 0;
}
push() {
const array = this.array;
const size = this.size;
const args = arguments.length;
if (this.pos + args >= size) {
let nusize = ((size * this.factor) | 0) + args;
let nuarray = new Float32Array(nusize);
nuarray.set(array);
this.array = nuarray;
this.size = nusize;
}
for (let i=0; i<args; i++) {
array[this.pos++] = arguments[i];
}
}
finalize() {
if (this.pos / this.size >= 0.9) {
return this.array.subarray(0, this.pos);
} else {
return this.array.slice(0, this.pos);
}
}
}

633
src2/geo/point.js Normal file
View file

@ -0,0 +1,633 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { THREE } from '../ext/three.js';
const { Vector3 } = THREE;
import { util, config, key } from './base.js';
export class Point {
constructor(x = 0, y = 0, z = 0, key) {
this.x = x;
this.y = y;
this.z = z;
if (key) {
this._key = key;
}
}
get key() {
if (this._key) {
return this._key;
}
return this._key = [
((this.x * 100000) | 0),
((this.y * 100000) | 0),
((this.z * 100000) | 0)
].join('');
}
toClipper() {
return {
X: this.x * config.clipper,
Y: this.y * config.clipper
};
}
toArray() {
return [ this.x, this.y, this.z ];
}
toVector3() {
return new THREE.Vector3(this.x, this.y, this.z);
}
set(x, y, z) {
this.x = x;
this.y = y;
this.z = z;
delete this._key;
return this;
}
setX(x) {
this.x = x;
return this;
}
setY(y) {
this.y = y;
return this;
}
setZ(z) {
this.z = z;
return this;
}
setA(a) {
this.a = a;
return this;
}
swapXZ() {
let p = this,
t = p.x;
p.x = p.z;
p.z = t;
return this;
}
swapYZ() {
let p = this,
t = p.y;
p.y = p.z;
p.z = t;
return this;
}
scale(x = 1, y = 1, z = 1) {
this.x *= x;
this.y *= y;
this.z *= z;
return this;
}
round(precision) {
return newPoint(
this.x.round(precision),
this.y.round(precision),
this.z.round(precision));
}
addFacet(facet) {
if (!this.group) this.group = [];
this.group.push(facet);
return this;
}
rekey() {
this._key = undefined;
}
toString() {
return this.key;
}
clone() {
let p = newPoint(this.x, this.y, this.z, this._key);
if (this.a !== undefined) {
p.a = this.a;
}
return p;
}
slopeTo(p) {
return base.newSlope(this, p);
}
lineTo(p, k) {
return base.newLine(this, p, k);
}
isNear(p, dist) {
return util.isCloseTo(this.x, p.x, dist) && util.isCloseTo(this.y, p.y, dist);
}
/**
* return distance to line connecting points p1, p2
* distance is calculated on the perpendicular (normal) to line
*
* @param {Point} p1
* @param {Point} p2
* @returns {number}
*/
distToLine(p1, p2) {
return Math.sqrt(this.distToLineSq(p1, p2));
}
distToLine3D(lp1, lp2) {
// Convert points to vectors
const p0 = [this.x, this.y, this.z];
const p1 = [lp1.x, lp1.y, lp1.z];
const p2 = [lp2.x, lp2.y, lp2.z];
// Calculate the direction vector of the line
const lineDir = [p2[0] - p1[0], p2[1] - p1[1], p2[2] - p1[2]];
// Calculate the vector from p1 to the point
const p1ToP = [p0[0] - p1[0], p0[1] - p1[1], p0[2] - p1[2]];
// Calculate the cross product of lineDir and p1ToP
const crossProd = [
lineDir[1] * p1ToP[2] - lineDir[2] * p1ToP[1],
lineDir[2] * p1ToP[0] - lineDir[0] * p1ToP[2],
lineDir[0] * p1ToP[1] - lineDir[1] * p1ToP[0]
];
// Calculate the magnitude of the cross product
const crossProdMag = Math.sqrt(crossProd[0] ** 2 + crossProd[1] ** 2 + crossProd[2] ** 2);
// Calculate the magnitude of the direction vector of the line
const lineDirMag = Math.sqrt(lineDir[0] ** 2 + lineDir[1] ** 2 + lineDir[2] ** 2);
// Distance from point to line is the magnitude of the cross product divided by the magnitude of the direction vector
const distance = crossProdMag / lineDirMag;
return distance;
}
/**
* used exclusively in new fill code. output does not agree with
* old distToLine, but is the only method that seems to work for
* fill. using new distToLine as a global replacement breaks support
* offset clipping. both need to be investigated and a single line
* normal distance needs to be formulated to replace both functions.
*/
distToLineNew(p1, p2) {
return p2l(this, p1, p2);
}
/**
* return square of distance to line connecting points p1, p2
* distance is calculated on the perpendicular (normal) to line
*/
distToLineSq(p1, p2) {
let p = this,
d = util.distSq(p1, p2);
let t = ((p.x - p1.x) * (p2.x - p1.x) + (p.y - p1.y) * (p2.y - p1.y)) / d;
if (t < 0) return util.distSq(p, p1);
if (t > 1) return util.distSq(p, p2);
return util.distSqv2(p.x, p.y, p1.x + t * (p2.x - p1.x), p1.y + t * (p2.y - p1.y));
}
withinDist2(p1, p2, dist2) {
let ll2 = p1.distToSq2D(p2),
dp1 = this.distToSq2D(p1),
dp2 = this.distToSq2D(p2);
// if the line segment described is less than dist2
// then add dist2 to ll2. if this point is not closer
// than newll2 to either point, then it can't be closer
// to the described segment than dist2
if (ll2 < dist2) {
ll2 += dist2;
if (dp1 > ll2 && dp2 > ll2) return false;
}
// if point is farther from each point that the distance
// between the points and that distance is greater than dist2
// then it's not possible for the point to be closer than
// dist2 to the described line segment.
if (dp1 > ll2 && dp2 > ll2) return false;
return this.distToLineSq(p1, p2) < dist2;
}
midPointTo(p2) {
return newPoint((this.x + p2.x) / 2, (this.y + p2.y) / 2, this.z);
}
midPointTo3D(p2) {
return newPoint(
(this.x + p2.x) / 2,
(this.y + p2.y) / 2,
(this.z + p2.z) / 2
);
}
/**
* non-scale corrected version of follow()
*/
projectOnSlope(slope, mult) {
return newPoint(
this.x + slope.dx * mult,
this.y + slope.dy * mult,
this.z);
}
followTo(point, mult) {
return this.follow(this.slopeTo(point), mult);
}
/**
* return a point along the line this from point to p2 offset
* by a distance. positive distances are closer to this point.
*/
offsetPointFrom(p2, dist) {
let p1 = this,
dx = p2.x - p1.x,
dy = p2.y - p1.y,
ls = dist / Math.sqrt(dx * dx + dy * dy),
ox = dx * ls,
oy = dy * ls;
return newPoint(p2.x - ox, p2.y - oy, p2.z, key.NONE);
}
/**
* return a point along the line this from point to p2 offset
* by a distance. positive distances are farther from this point.
*/
offsetPointTo(p2, dist) {
let p1 = this,
dx = p2.x - p1.x,
dy = p2.y - p1.y;
if (dx === 0 && dy === 0) return this;
let ls = dist / Math.sqrt(dx * dx + dy * dy),
ox = dx * ls,
oy = dy * ls;
return newPoint(p1.x + ox, p1.y + oy, p2.z, key.NONE);
}
offsetLineTo(p2, offset) {
let p1 = this,
dx = p2.x - p1.x,
dy = p2.y - p1.y,
ls = offset / Math.sqrt(dx * dx + dy * dy),
ox = dx * ls,
oy = dy * ls,
np1 = newPoint(p1.x - oy, p1.y + ox, p1.z, key.NONE),
np2 = newPoint(p2.x - oy, p2.y + ox, p2.z, key.NONE);
np1.op = p1;
np2.op = p2;
return base.newLine(np1, np2, key.NONE);
}
offset(x, y, z) {
return newPoint(this.x + x, this.y + y, this.z + z);
}
/**
* checks if a point is inside of a polygon
* does not check children/holes
*/
inPolygon(poly) {
if (!poly.bounds.containsXY(this.x, this.y)) return false;
let p = poly.points,
pl = p.length,
p1, p2, i, inside = false;
for (i = 0; i < pl; i++) {
p1 = p[i];
p2 = p[(i + 1) % pl];
if ((p1.y >= this.y) != (p2.y >= this.y) &&
(this.x <= (p2.x - p1.x) * (this.y - p1.y) / (p2.y - p1.y) + p1.x)) {
inside = !inside;
}
}
return inside;
}
/**
* returns true if the point is inside of a polygon but
* not inside any of it's children
*/
isInPolygon(poly) {
let point = this,
i;
if (Array.isArray(poly)) {
for (i = 0; i < poly.length; i++) {
if (point.isInPolygon(poly[i])) return true;
}
return false;
}
let holes = poly.inner;
if (point.inPolygon(poly) || point.nearPolygon(poly, config.precision_merge_sq)) {
for (i = 0; holes && i < holes.length; i++) {
if (point.inPolygon(holes[i]) && !point.nearPolygon(holes[i], config.precision_merge_sq)) return false;
}
return true;
}
return false;
}
/**
* returns true if the point is inside of a polygon but
* not inside any of it's children
*/
isInPolygonOnly(poly) {
let point = this,
i;
if (Array.isArray(poly)) {
for (i = 0; i < poly.length; i++) {
if (point.isInPolygonOnly(poly[i])) {
return true;
}
}
return false;
}
let holes = poly.inner;
if (point.inPolygon(poly)) {
for (i = 0; holes && i < holes.length; i++) {
if (point.inPolygon(holes[i])) return false;
}
return true;
}
return false;
}
/**
* checks if point is near polygon edge. distance is squared.
* @param {boolean} [inner] process inner polygons
*/
nearPolygon(poly, dist2, inner) {
// throw new Error("nearPolygon");
for (let i = 0, p = poly.points, pl = p.length; i < pl; i++) {
if (this.withinDist2(p[i], p[(i + 1) % pl], dist2)) {
return true;
}
}
if (inner && poly.inner) {
for (let i = 0; i < poly.inner.length; i++) {
if (this.nearPolygon(poly.inner[i], dist2)) return true;
}
}
return false;
}
/**
* returns true if point will not be trimmed later
*/
insideOffset(poly, offset, mindist2) {
return this.inPolygon(poly) === (offset > 0) && !this.nearPolygon(poly, mindist2);
}
/**
* returns a new point following given slope for given distance
* same as projectOnSlope() but scaled
*/
follow(slope, distance) {
let ls = distance / Math.sqrt(slope.dx * slope.dx + slope.dy * slope.dy);
return newPoint(this.x + slope.dx * ls, this.y + slope.dy * ls, this.z);
}
/**
* for point, return intersecting point on z to next point if points
* are on either size of z
*/
intersectZ(p, z) {
let dx = p.x - this.x,
dy = p.y - this.y,
dz = p.z - this.z,
pct = 1 - ((p.z - z) / dz);
return newPoint(this.x + dx * pct, this.y + dy * pct, this.z + dz * pct);
}
isEqual2D(p) {
return this === p || (this.x === p.x && this.y === p.y);
}
/**
* returns true if points are close enough to be considered equivalent
*/
isMergable2D(p) {
return this.isEqual2D(p) || (this.distToSq2D(p) < config.precision_merge_sq);
}
/**
* compares 3D point
*/
isEqual(p) {
return this === p || (this.x === p.x && this.y === p.y && this.z === p.z);
}
isEqual2D(p) {
return this === p || (this.x === p.x && this.y === p.y);
}
/**
* returns true if points are close enough to be considered equivalent
*/
isMergable3D(p) {
return this.isEqual(p) || (this.distToSq3D(p) < config.precision_merge_sq);
}
/**
* return true if point is inside 2D square size dist*2 around p
*/
isInBox(p, dist) {
return Math.abs(this.x - p.x) < dist && Math.abs(this.y - p.y) < dist;
}
/**
* return min distance from point to a polygon
* stops searching if any point is closer than threshold
*
* @param {Polygon} poly
* @param {number} [threshold] stop looking if under threshold
*/
distToPolySegments(poly, threshold) {
let point = this,
mindist = Infinity;
poly.forEachSegment(function(p1, p2) {
const nextdist = Math.min(mindist, point.distToLine(p1, p2));
mindist = Math.min(nextdist, mindist);
// returning true terminates forEachSegment()
if (mindist <= threshold) return true;
});
return mindist;
}
/**
* @param {Polygon} poly
* @param {number} [threshold] stop looking if under threshold
*/
distToPolyPoints(poly, threshold) {
let point = this,
mindist = Infinity;
poly.forEachPoint(function(pp) {
mindist = Math.min(mindist, point.distTo2D(pp));
if (mindist < threshold) return true;
});
return mindist;
}
/**
* @returns {Point} nearest point (less than max) from array to this point
*/
nearestTo(points, max) {
if (!max) throw "missing max";
let mind = Infinity,
minp = null,
i, p, d;
for (i = 0; i < points.length; i++) {
p = points[i];
if (p === this || p.del) continue;
d = this.distToSq2D(p);
if (d < max && d < mind) {
mind = d;
minp = p;
}
}
return minp;
}
/**
* @param {Point[]} points
* @return {number} average square dist to cloud of points
*/
averageDistTo(points) {
let sum = 0.0,
count = 0,
i;
for (i = 0; i < points.length; i++) {
if (points[i] != this) {
sum += this.distToSq2D(points[i]);
count++;
}
}
return sum / count;
}
/**
* dist to point in 2D
*/
distTo2D(p) {
let dx = this.x - p.x,
dy = this.y - p.y;
return Math.sqrt(dx * dx + dy * dy);
}
/**
* square of distance in 2D
*/
distToSq2D(p) {
let dx = this.x - p.x,
dy = this.y - p.y;
return dx * dx + dy * dy;
}
distTo3D(p) {
let dx = this.x - p.x,
dy = this.y - p.y,
dz = this.z - p.z;
return Math.sqrt(dx * dx + dy * dy + dz * dz);
}
/**
* square of distance in 3D
*/
distToSq3D(p) {
let dx = this.x - p.x,
dy = this.y - p.y,
dz = this.z - p.z;
return dx * dx + dy * dy + dz * dz;
}
/**
* returns true if point is inside triangle described by three points
*/
inTriangle(a, b, c) {
let as_x = this.x - a.x,
as_y = this.y - a.y,
s_ab = (b.x - a.x) * as_y - (b.y - a.y) * as_x > 0;
if ((c.x - a.x) * as_y - (c.y - a.y) * as_x > 0 == s_ab) return false;
if ((c.x - b.x) * (this.y - b.y) - (c.y - b.y) * (this.x - b.x) > 0 != s_ab) return false;
return true;
}
/**
* returns true if point is on a line described by two points.
* test sum of distances p1->this + this->p2 ~= p1->p2 whens
* slopes from p1->this same as this->p2
*/
onLine(p1, p2) {
return this.distToLine(p1, p2) < config.precision_point_on_line;
}
add(delta) {
return newPoint(this.x + delta.x, this.y + delta.y, this.z + delta.z);
}
sub(delta) {
return newPoint(this.x - delta.x, this.y - delta.y, this.z - delta.z);
}
move(delta) {
this.x += delta.x;
this.y += delta.y;
this.z += delta.z;
return this;
}
// radians rotatition in XY around origin
rotate(angle) {
const { x, y } = this;
this.x = x * Math.cos(angle) - y * Math.sin(angle);
this.y = y * Math.cos(angle) + x * Math.sin(angle);
}
}
// Helper functions
function dot(u, v) {
return u.x * v.x + u.y * v.y;
}
function norm(v) {
return Math.sqrt(dot(v, v));
}
function d(u, v) {
return Math.sqrt(sqr(u.x - v.x) + sqr(u.y - v.y));
}
function p2l(p, l1, l2) {
let v = l2.clone().sub(l1);
let w = p.clone().sub(l1);
let proj = w.dot(v) / v.dot(v);
if (proj < 0) return w.length();
if (proj > 1) return p.distTo(l2);
return w.sub(v.multiplyScalar(proj)).length();
}
export function newPoint(x, y, z, key) {
return new Point(x, y, z, key);
}
export function pointFromClipper(cp, z) {
return newPoint(cp.X / config.clipper, cp.Y / config.clipper, z);
}

128
src2/geo/points.js Normal file
View file

@ -0,0 +1,128 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
"use strict";
import { newPoint } from '../geo/point.js';
const { config } = base;
/**
* converts a geometry point array into a kiri point array
* with optional decimation
*/
export function verticesToPoints(array, options) {
let parr = new Array(array.length / 3),
i = 0,
j = 0,
t = Date.now(),
hash = {},
unique = 0,
passes = 0,
points,
oldpoints = parr.length,
newpoints;
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 : config.decimate_threshold;
precision = precision >= 0 ? precision : config.precision_decimate;
maxpass = maxpass >= 0 ? maxpass : 0;
// replace point objects with their equivalents
if (maxpass) {
while (i < array.length) {
let p = newPoint(array[i++], array[i++], array[i++]),
k = p.key,
m = hash[k];
if (!m) {
m = p;
hash[k] = p;
unique++;
}
parr[j++] = m;
}
} else {
while (i < array.length) {
parr[j++] = newPoint(array[i++], array[i++], array[i++]);
}
}
// 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];
// skip lines longer than precision threshold
if (line.d >= precision) break;
// skip lines where one of the points is already offset
if (line.p1.op || line.p2.op) continue;
// todo skip dropping lines where either point is a "sharp" on 3 vectors
// todo skip dropping lines where either point connects to a "long" line
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) console.log({
before: array.length / 3,
after: parr.length,
unique: unique,
decimations: passes,
time: (Date.now() - t)
});
return parr;
}
export 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;
}

2386
src2/geo/polygon.js Normal file

File diff suppressed because it is too large Load diff

1109
src2/geo/polygons.js Normal file

File diff suppressed because it is too large Load diff

884
src2/geo/slicer.js Normal file
View file

@ -0,0 +1,884 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
"use strict";
/**
* basic slice and line connection. In future, replace kiri's fdm and cam slicers
* with wrappers on this one.
*/
// dep: geo.base
// dep: geo.line
// dep: geo.point
// dep: geo.polygon
// dep: geo.polygons
import { newPolygon, newPoint, newOrderedLine } from '../geo/polygon.js';
gapp.register("geo.slicer", [], (root, exports) => {
const { base } = root;
const { config, util, polygons } = base
const POLY = base.polygons;
function dval(v, dv) {
return v !== undefined ? v : dv;
}
/**
* Given an array of points as triples, a bounding box and a set of
* slicing controls, emit an array of Slice objects to the ondone()
* function. onupdate() will be called with two parameters (% completion
* and an optional message) so that the UI can report progress to the user.
*
* @param {Point[]} points vertex array
* @param {Object} options slicing parameters
*/
async function slice(points, options = {}) {
let zMin = options.zMin || 0,
zMax = options.zMax || 0,
zInc = options.zInc || 0,
zGen = options.zGen, // optional z index generator function
zIndexes = options.indices || [],
minStep = options.minstep || 0,
zFlat = {}, // map area of z index flat areas
zList = {}, // fast map of z indexes
zLine = [], // map of line counts co-linear to z plane
zScale, // bucket span in z units
zSum = 0.0, // sanity check that points enclose non-zere volume
buckets = [], // banded/grouped faces to speed up slice/search
overlapMax = options.overlap || 0.75,
bucketMax = options.bucketMax || 100,
onupdate = options.onupdate || function() {},
sliceFn = dval(options.slicer, sliceZ),
{ debug, flat, autoDim } = options,
i, j, p1, p2, p3;
if (!(points && points.length)) {
throw "missing points array";
}
// convert threejs position array into points array
if (flat) {
let array = [];
for (i=0, j=points.length; i<j; ) {
array.push(newPoint(
points[i++].round(3),
points[i++].round(3),
points[i++].round(3)
));
}
points = array;
} else {
// round points
for (i = 0; i < points.length; i++) {
points[i] = points[i].round(3);
}
}
// gather z-index stats
for (i = 0; i < points.length;) {
p1 = points[i++];
p2 = points[i++];
p3 = points[i++];
// used to calculate buckets (rough sum of z span)
zSum += (Math.abs(p1.z - p2.z) + Math.abs(p2.z - p3.z) + Math.abs(p3.z - p1.z));
// use co-flat and co-line detection to adjust slice Z
if (p1.z === p2.z && p2.z === p3.z && p1.z >= zMin) {
// detect faces co-planar with Z and sum the enclosed area
let zkey = p1.z,
area = Math.abs(util.area2(p1,p2,p3)) / 2;
if (!zFlat[zkey]) {
zFlat[zkey] = area;
} else {
zFlat[zkey] += area;
}
zLine[zkey] = (zLine[zkey] || 0) + 1;
} else if (p1.z === p2.z) {
zLine[p1.z] = (zLine[p1.z] || 0) + 1;
} else if (p2.z === p3.z) {
zLine[p2.z] = (zLine[p2.z] || 0) + 1;
} else if (p3.z === p1.z) {
zLine[p3.z] = (zLine[p3.z] || 0) + 1;
}
if (autoDim) {
zMin = Math.min(zMin, p1.z, p2.z, p3.z);
zMax = Math.max(zMax, p1.z, p2.z, p3.z);
}
zList[p1.z] = p1.z;
zList[p2.z] = p2.z;
zList[p3.z] = p3.z;
}
if (zInc) {
for (i = zMin; i <= zMax; i += zInc) {
zIndexes.push(i);
}
} else if (!zIndexes.length) {
zIndexes = Object.values(zList).sort((a,b) => a - b);
if (minStep > 0) {
let lastOut;
zIndexes = zIndexes.filter(v => {
if (lastOut !== undefined && v - lastOut < minStep) {
return false;
} else {
lastOut = v;
return true;
}
});
}
}
// allow zGen to override or update zIndexes
// FDM, CAM, Laser slicers will use this to align or interpolate layers
if (zGen) {
zIndexes = zGen({ zMin, zMax, zLine, zFlat, zIndexes, options });
}
// ensure bucket aligmnent
zIndexes = zIndexes.map(v => v.round(3));
/**
* bucket polygons into z-bounded groups (inside or crossing)
* to reduce the search space in complex models
*/
let zSpan = zMax - zMin;
let zSpanAvg = zSum / points.length;
let bucketCount = options.bucket !== false ?
Math.min(bucketMax, Math.max(1, Math.floor(zSpan / zSpanAvg))) : 1;
zScale = 1 / (zMax / bucketCount);
if (debug) {
console.log({
zMin, zMax, zIndexes, zScale, zSum, zSpanAvg,
points, bucketCount,
options, buckets
});
}
/** short-circuit for microscopic and invalid objects */
if (zSpan == 0 || zSum == 0 || points.length == 0) {
return {};
}
// create empty buckets
for (i = 0; i < bucketCount; i++) {
buckets.push({ points: [], slices: [] });
}
if (bucketCount > 1) {
let failAt = (points.length * overlapMax) | 0, bucket;
// copy triples into all matching z-buckets
outer: for (i = 0; i < points.length;) {
p1 = points[i++];
p2 = points[i++];
p3 = points[i++];
let zm = Math.max(0, Math.min(p1.z, p2.z, p3.z)),
zM = Math.max(p1.z, p2.z, p3.z),
bm = Math.floor(zm * zScale),
bM = Math.min(Math.ceil(zM * zScale), bucketCount);
// add point to all buckets in range
for (j = bm; j < bM; j++) {
bucket = buckets[j].points;
bucket.push(p1);
bucket.push(p2);
bucket.push(p3);
// fail if single bucket exceeds threshold
if (bucket.length > failAt) {
if (debug) console.log({ bucketFail: bucket.length });
bucketCount = 1;
break outer;
}
}
}
}
// fallback if we can't partition point space
if (bucketCount === 1) {
buckets = [{ points, slices: [] }];
}
// create buckets data structure
for (let i = 0, l = zIndexes.length; i < l; i++) {
let z = zIndexes[i],
index = bucketCount <= 1 ? 0 :
Math.min(Math.floor(z * zScale), bucketCount - 1),
bucket = buckets[index];
if (bucket) {
bucket.slices.push(z);
} else {
console.log({ missing_bucket: z, index });
}
onupdate((i / zIndexes.length) * 0.1);
}
async function sliceBuckets() {
let output = [];
let count = 0;
let opt = { ...options, zMin, zMax, zIndexes };
let ps = [];
for (let i = 0, l = buckets.length; i < l; i++) {
let bucket = buckets[i];
let { points, slices } = bucket;
if (slices.length)
ps.push(sliceFn(slices, points, {
...opt,
each(rval) {
output.push(rval);
onupdate(0.1 + (count++ / zIndexes.length) * 0.9);
}
}));
}
// join all returned promises
await Promise.all(ps);
return output;
}
// create slices from each bucketed region
let slices = sliceFn ? await sliceBuckets() : [];
slices = slices.sort((a,b) => a.z - b.z);
return { slices, points, zMin, zMax, zIndexes, zFlat };
}
/**
* given a point, append to the correct
* 'where' objec tarray (on, over or under)
*
* @param {Point} p
* @param {number} z offset
* @param {Obejct} where
*/
function checkUnderOverOn(p, z, where) {
let delta = p.z - z;
if (Math.abs(delta) < config.precision_slice_z) { // on
where.on.push(p);
} else if (delta < 0) { // under
where.under.push(p);
} else { // over
where.over.push(p);
}
}
/**
* Given a point over and under a z offset, calculate
* and return the intersection point on that z plane
*
* @param {Point} over
* @param {Point} under
* @param {number} z offset
* @returns {Point} intersection point
*/
function intersectPoints(over, under, z) {
let ip = [];
for (let i = 0; i < over.length; i++) {
for (let j = 0; j < under.length; j++) {
ip.push(over[i].intersectZ(under[j], z));
}
}
return ip;
}
/**
* Ensure points are unique with a cache/key algorithm
*/
function getCachedPoint(phash, p) {
let cached = phash[p.key];
if (!cached) {
phash[p.key] = p;
return p;
}
return cached;
}
/**
* Given two points and hints about their edges,
* return a new Line object with points sorted
* lexicographically by key. This allows for future
* line de-duplication and joins.
*
* @param {Object} phash
* @param {Point} p1
* @param {Point} p2
* @param {boolean} [coplanar]
* @param {boolean} [edge]
* @returns {Line}
*/
function makeZLine(phash, p1, p2, coplanar, edge) {
p1 = getCachedPoint(phash, p1);
p2 = getCachedPoint(phash, p2);
let line = newOrderedLine(p1,p2);
line.coplanar = coplanar || false;
line.edge = edge || false;
return line;
}
/**
* process a single z-slice on a single mesh and
* add to slices array
*
* @param {number} z
*/
async function sliceZ(z, points, options = {}) {
if (Array.isArray(z)) {
return Promise.all(z.map(z => sliceZ(z, points, options)));
}
let { zMin, zMax, under, over, both, each } = options,
groupFn = dval(options.groupr, both ? null : sliceConnect),
phash = {},
lines = [],
p1, p2, p3;
// default to 'over' selection with 2 points on a line
if (!under && !both) over = true;
// iterate over matching buckets for this z offset
for (let i = 0; i < points.length; ) {
p1 = points[i++];
p2 = points[i++];
p3 = points[i++];
let where = {under: [], over: [], on: []};
checkUnderOverOn(p1, z, where);
checkUnderOverOn(p2, z, where);
checkUnderOverOn(p3, z, where);
if (where.under.length === 3 || where.over.length === 3) {
// does not intersect (all 3 above or below)
} else if (where.on.length === 2) {
// one side of triangle is on the Z plane and 3rd is below
// drop lines with 3rd above because that leads to ambiguities
// with complex nested polygons on flat surface
let add2 = both ||
(over && (where.over.length === 1 || z === zMax)) ||
(under && (where.under.length === 1 || z === zMin));
if (add2) {
lines.push(makeZLine(phash, where.on[0], where.on[1], false, true));
}
} else if (where.on.length === 3) {
// triangle is coplanar with Z
// we drop these because this face is attached to 3 others
// that will satisfy the if above (line) with 2 points
} else if (where.under.length === 0 || where.over.length === 0) {
// does not intersect but one point is on the slice Z plane
} else {
// compute two point intersections and construct line
let line = intersectPoints(where.over, where.under, z);
if (line.length < 2 && where.on.length === 1) {
line.push(where.on[0]);
}
if (line.length === 2) {
lines.push(makeZLine(phash, line[0], line[1]));
} else {
console.log({msg: "invalid ips", line: line, where: where});
}
}
}
if (lines.length == 0 && options.noEmpty) {
return;
}
// de-dup and group lines
lines = removeDuplicateLines(lines);
let rval = { z, lines };
if (groupFn) {
let groups = groupFn(lines, z, options);
if (options.xor) {
groups = polygons.xor(groups);
}
if (options.union) {
let points = groups.map(p => p.length);
if (points.length > 1) points = points.reduce((a,b) => a + b);
// simplistic healing of non-manifold meshes
let opt = { x: 1 };
let union = polygons.union(polygons.nest(groups), 0.1, true, opt);
// fall back to xor'ing polygons that might overlap
// when one does not cleanly contain the other and we lose lots of points
// trigger when 2 polygons and we lose > 40% of points in the union
let delta = opt.changes < 0 ? Math.abs(opt.changes / points) : 0;
if (groups.length === 2 && delta >= 0.4) {
let xor = groups[0].xor(groups[1]);
console.log({ points, pct: (opt.changes / points).round(3), xor: xor.length });
if (xor.length) {
union = xor;
}
}
// track total poly length changes to determine if healed
rval.changes = opt.changes;
groups = polygons.flatten(union, null, true);
}
rval.groups = groups;
}
// look for driver-specific slice post-processor
if (options.post) {
let fn = base.slicePost[options.post];
if (fn) fn(rval, options);
}
// free objects to be re-claimed and reduce memory pressure
if (false) {
delete rval.groups
delete rval.lines
if (rval.tops)
for (let top of rval.tops) {
if (top.poly) {
top.poly.freeParentRefs();
top.simple.freeParentRefs();
for (let shell of top.shells) shell.freeParentRefs();
for (let fillo of top.fill_off) fillo.freeParentRefs();
for (let last of top.last) last.freeParentRefs();
} else {
top.freeParentRefs();
}
}
if (rval.clip) {
for (let clip of rval.clip) clip.freeParentRefs();
if (rval.clip.m_AllPolys) delete rval.clip.m_AllPolys
}
}
if (each) each(rval);
return rval;
}
/**
* Given an array of input lines (line soup), find the path through
* joining line ends that encompasses the greatest area without self
* interesection. Eliminate used points and repeat. Unjoined lines
* are permitted and handled after all other cases are handled.
*
* @param {Line[]} input
* @param {number} [index]
* @returns {Array}
*/
function sliceConnect(input, z, opt = {}) {
let { debug, both } = opt;
if (both) {
if (debug) console.log('unable to connect lines sliced with "both" option');
return [];
}
// map points to all other points they're connected to
let pmap = {},
points = [],
output = [],
connect = [],
emitted = 0,
forks = false,
frays = false,
bridge = config.bridgeLineGapDistance,
bridgeMax = config.bridgeLineGapDistanceMax,
p1, p2, gl;
function cachedPoint(p) {
let cp = pmap[p.key];
if (cp) return cp;
points.push(p);
pmap[p.key] = p;
return p;
}
function addConnected(p1, p2) {
if (!p1.group) p1.group = [ p2 ];
else p1.group.push(p2);
}
function perimeter(array) {
if (!array.perimeter) {
array.perimeter = newPolygon().addPoints(array).perimeter();
}
return array.perimeter;
}
/**
* follow points through connected lines to form candidate output paths
*/
function findNextPath(point, current, branches, depth = 1) {
let path = [];
if (current) {
current.push(path);
}
for (;;) {
// prevent point re-use
point.del = true;
// add point to path
path.push(point);
let links = point.group.filter(p => !p.del);
// no need to recurse at the start
if (links.length === 2 && depth === 1) {
point = links[0];
// if (debug) console.log({start_mid: point, depth});
continue;
}
// if fork in the road, follow all paths to their end
// and find the longest path
let root = !current, nc;
if (links.length > 1) {
// if (debug) console.log('fork!', {links: links.length, depth, root});
if (root) {
current = [ path ];
branches = [ ];
}
if (branches.length < 500)
for (let p of links) {
branches.push(nc = current.slice());
let rpath = findNextPath(p, nc, branches, depth + 1);
// allow point re-use in other path searches
for (let p of rpath) p.del = false;
}
// flatten and sort in ascending perimeter
let flat = branches.map(b => b.flat()).sort((a,b) => {
return perimeter(b) - perimeter(a);
});
let npath = flat[0];
if (debug) console.log({
root,
branches: branches.slice(),
flat, path, npath
});
if (root) {
for (let p of npath) p.del = true;
return npath;
} else {
return path;
}
// return root ? npath : path;
} else {
// choose next (unused) point
point = links[0];
}
// hit an open end or branch
if (!point || point.del) {
return path;
}
}
throw "invalid state";
}
// emit a polygon if it can be cleaned and still have 2 or more points
function emit(poly) {
emitted += poly.length;
if (!opt.dirty) poly = poly.clean();
if (poly.length > 2 || true) output.push(poly);
if (debug) console.log('xray',poly);
}
// given an array of paths, emit longest to shortest
// eliminating points from the paths as they are emitted
// shorter paths any point eliminated are eliminated as candidates.
function emitPath(path) {
let closed = path[0].group.indexOf(path.peek()) >= 0;
if (closed && path.length > 2) {
if (debug) console.log({ closed: path.length, path });
emit(newPolygon().addPoints(path));
} else if (path.length > 1) {
let gap = path[0].distTo2D(path.peek()).round(4);
if (debug) console.log({ open: path.length, gap, path });
connect.push(path);
}
}
// create point map, unique point list and point group arrays
input.forEach(function(line) {
p1 = cachedPoint(line.p1);
p2 = cachedPoint(line.p2);
addConnected(p1,p2);
addConnected(p2,p1);
});
// console.log({points, forks: points.filter(p => p.group.length !== 2)});
// for each unused point, find the longest non-intersecting path
for (let point of points) {
gl = point.group.length;
forks = forks || gl > 2;
frays = frays || gl < 2;
}
if (debug && (forks || frays)) console.log({forks, frays});
// process paths starting with forks
if (forks) {
if (debug) console.log('process forks');
for (let point of points) {
// must not have been used and be a dangling end
if (!point.del && point.group.length > 2) {
let path = findNextPath(point);
if (path) emitPath(path);
}
} }
// process paths with dangling endpoints
if (frays) {
if (debug) console.log('process frays');
for (let point of points) {
// must not have been used and be a dangling end
if (!point.del && point.group.length === 1) {
let path = findNextPath(point);
if (path) emitPath(path);
}
} }
// process normal paths
if (debug) console.log('process mids');
for (let point of points) {
// must not have been used and be a dangling end
if (!point.del) {
let path = findNextPath(point);
if (path) emitPath(path);
}
}
if (debug) console.log({
points,
emitted,
used: points.filter(p => p.del),
free: points.filter(p => !p.del),
});
if (debug && connect.length) console.log({connect});
if (debug) connect = connect.map(a => a.slice());
// progressively connect open polygons within a bridge distance
let iter = 1000;
let mingap;
if (true) do {
mingap = Infinity;
outer: for (let i=0; i<connect.length; i++) {
if (!bridge) {
emit(newPolygon().addPoints(root).setOpen());
continue;
}
// rollup root with arrays after until no more ends match
inner: while (true) {
let root = connect[i];
if (root.delete) break;
let rfirst = root[0],
rlast = root.peek(),
dist = rfirst.distToSq2D(rlast),
closest = { dist };
for (let j=i+1; j<connect.length; j++) {
let next = connect[j];
if (next.delete) continue;
let nfirst = next[0];
let nlast = next.peek();
// test last to next first
dist = rlast.distToSq2D(nfirst);
mingap = Math.min(mingap, dist);
if (dist < closest.dist && dist <= bridge) {
closest = { dist, next }
}
// test last to next last
dist = rlast.distToSq2D(nlast);
mingap = Math.min(mingap, dist);
if (dist < closest.dist && dist <= bridge) {
closest = { dist, next, reverse: next };
}
// test first to next first
dist = rfirst.distToSq2D(nfirst);
mingap = Math.min(mingap, dist);
if (dist < closest.dist && dist <= bridge) {
closest = { dist, next, reverse: root };
}
// test last to next last
dist = rfirst.distToSq2D(nlast);
mingap = Math.min(mingap, dist);
if (dist < closest.dist && dist <= bridge) {
closest = { dist, next, swap: j };
}
}
let { next, reverse, swap } = closest;
if (next && closest.dist < bridge) {
if (debug) console.log({
rollup: root.slice(),
next: next.slice(),
reverse,
swap
});
if (reverse) {
reverse.reverse();
}
if (swap) {
next.appendAll(root);
connect[i] = next;
connect[swap] = root;
root.delete = true;
next.merged = true;
} else {
root.appendAll(next);
next.delete = true;
root.merged = true;
}
} else {
break inner;
}
}
}
bridge = mingap < Infinity ? Math.max(mingap + 0.01, bridge + 0.1) : bridge + 0.1;
if (debug) console.log({iter, bridge, mingap, bridgeMax});
} while (iter-- > 0 && bridge && bridge < bridgeMax && mingap < bridgeMax);
if (debug) console.log({ remain: connect.filter(c => !c.delete) });
for (let array of connect) {
if (array.delete) continue;
if (debug) {
let first = array[0];
let last = array.peek();
let dist = first.distToSq2D(last);
console.log({
dist: dist.round(4),
merged: array.merged || false,
array
});
}
emit(newPolygon().addPoints(array));
}
if (debug) console.log({ emitted, output });
if (debug && emitted < points.length) console.log({ leftovers:points.length - emitted });
return output;
}
/**
* eliminate duplicate lines and interior-only lines (coplanar)
*
* lines are sorted using lexicographic point keys such that
* they are comparable even if their points are reversed. hinting
* for deletion, co-planar and suspect shared edge is detectable at
* this time.
*
* @param {Line[]} lines
* @returns {Line[]}
*/
function removeDuplicateLines(lines) {
let output = [],
tmplines = [],
points = [],
pmap = {};
function cachePoint(p) {
let cp = pmap[p.key];
if (cp) return cp;
points.push(p);
pmap[p.key] = p;
return p;
}
function addLinesToPoint(point, line) {
cachePoint(point);
if (!point.group) point.group = [ line ];
else point.group.push(line);
}
// mark duplicates for deletion preserving edges
lines.sort(function (l1, l2) {
if (l1.key === l2.key) {
l1.del = !l1.edge;
l2.del = !l2.edge;
return 0;
}
return l1.key < l2.key ? -1 : 1;
});
// associate points with their lines, cull deleted
for (let line of lines) {
if (!line.del) {
tmplines.push(line);
addLinesToPoint(line.p1, line);
addLinesToPoint(line.p2, line);
}
}
// merge collinear lines
for (let point of points) {
// only merge when point connects to exactly one other point
if (point.group.length != 2) {
continue;
}
let l1 = point.group[0],
l2 = point.group[1];
if (l1.isCollinear(l2)) {
l1.del = true;
l2.del = true;
// find new endpoints that are not shared point
let p1 = l1.p1 != point ? l1.p1 : l1.p2,
p2 = l2.p1 != point ? l2.p1 : l2.p2,
newline = newOrderedLine(p1,p2);
// remove deleted lines from associated points
p1.group.remove(l1);
p1.group.remove(l2);
p2.group.remove(l1);
p2.group.remove(l2);
// associate new line with points
p1.group.push(newline);
p2.group.push(newline);
// add new line to lines array
newline.edge = l1.edge || l2.edge;
tmplines.push(newline);
}
}
// mark duplicates for deletion
// but preserve one if it's an edge
tmplines.sort(function (l1, l2) {
if (l1.key === l2.key) {
l1.del = true;
l2.del = !l2.edge;
return 0;
}
return l1.key < l2.key ? -1 : 1;
});
// create new line array culling deleted
for (let line of tmplines) {
if (!line.del) {
output.push(line);
line.p1.group = null;
line.p2.group = null;
}
}
return output;
}
gapp.overlay(base, {
slice,
sliceZ,
slicePost: {},
sliceDedup: removeDuplicateLines,
sliceConnect
});
});

100
src2/geo/slope.js Normal file
View file

@ -0,0 +1,100 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
"use strict";
import { config } from './base.js';
const ABS = Math.abs,
DEG2RAD = Math.PI / 180,
RAD2DEG = 180 / Math.PI;
export class Slope {
constructor(p1, p2, dx, dy) {
this.dx = p1 && p2 ? p2.x - p1.x : dx;
this.dy = p1 && p2 ? p2.y - p1.y : dy;
this.angle = Math.atan2(this.dy, this.dx) * RAD2DEG;
}
toString() {
return [this.dx, this.dy, this.angle].join(',');
}
clone() {
return new Slope(null, null, this.dx, this.dy);
}
isSame(s) {
// if very close to vertical or horizontal, they're the same
if (ABS(this.dx) <= config.precision_merge && ABS(s.dx) <= config.precision_merge) return true;
if (ABS(this.dy) <= config.precision_merge && ABS(s.dy) <= config.precision_merge) return true;
// check angle within a range
let prec = Math.min(1/Math.sqrt(this.dx * this.dx + this.dy * this.dy), config.precision_slope_merge);
return angleWithinDelta(this.angle, s.angle, prec || config.precision_slope);
}
normal() {
let t = this.dx;
this.dx = -this.dy;
this.dy = t;
this.angle = Math.atan2(this.dy, this.dx) * RAD2DEG;
return this;
}
invert() {
this.dx = -this.dx;
this.dy = -this.dy;
this.angle = Math.atan2(this.dy, this.dx) * RAD2DEG;
return this;
}
toUnit() {
let max = Math.max(ABS(this.dx), ABS(this.dy));
this.dx = this.dx / max;
this.dy = this.dy / max;
return this;
}
factor(f) {
this.dx *= f;
this.dy *= f;
return this;
}
invert() {
this.dx = -this.dx;
this.dy = -this.dy;
this.angle = 180 - this.angle;
return this;
}
angleDiff(s2,sign) {
const n1 = this.angle;
const n2 = s2.angle;
let diff = n2 - n1;
while (diff < -180) diff += 360;
while (diff > 180) diff -= 360;
return sign ? diff : Math.abs(diff);
}
}
/**
* returns true if the difference between a & b is less than v
*/
function minDeltaABS(a,b,v) {
return ABS(a-b) < v;
}
function angleWithinDelta(a1, a2, delta) {
return (ABS(a1-a2) <= delta || 360-ABS(a1-a2) <= delta);
}
export function newSlope(p1, p2, dx, dy) {
return new Slope(p1, p2, dx, dy);
}
export function newSlopeFromAngle(angle) {
return newSlope(0,0,
Math.cos(angle * DEG2RAD),
Math.sin(angle * DEG2RAD)
);
}

202
src2/geo/wasm.js Normal file
View file

@ -0,0 +1,202 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { base } from '../geo/base.js';
const { config } = base;
const factor = config.clipper;
export const wasm_ctrl = {
enable,
disable,
count: {
offset: 0,
union: 0,
diff: 0
}
};
function log() {
console.log(...arguments);
}
function writePolys(view, polys) {
let pcount = 0;
for (let poly of polys) {
pcount += writePoly(view, poly);
}
return pcount;
}
function writePoly(view, poly, inner) {
if (inner) {
poly.setCounterClockwise();
} else {
poly.setClockwise();
}
let count = 1;
let points = poly.points;
let inners = poly.inner;
view.writeU16(points.length, true);
for (let i=0, il=points.length; i<il; i++) {
let point = points[i];
view.writeI32((point.x * factor)|0, true);
view.writeI32((point.y * factor)|0, true);
}
if (inners) {
for (let i=0, il=inners.length; i<il; i++) {
count += writePoly(view, inners[i], true);
}
}
return count;
}
function readPoly(view, z) {
let points = view.readU16(true);
if (points === 0) return;
let poly = base.newPolygon();
while (points-- > 0) {
poly.add(view.readI32(true)/factor, view.readI32(true)/factor, z || 0);
}
return poly;
}
function readPolys(view, z, out = []) {
for (;;) {
let poly = readPoly(view, z);
if (poly) {
out.push(poly);
} else {
break;
}
}
return out;
}
export function polyOffset(polys, offset, z, clean, simple) {
wasm_ctrl.count.offset++;
let wasm = base.wasm,
buffer = wasm.shared,
pcount = writePolys(new DataWriter(wasm.heap, buffer), polys),
resat = wasm.fn.offset(buffer, pcount, offset * factor, clean, simple),
out = readPolys(new DataReader(wasm.heap, resat), z);
return polyNest(out);
}
export function polyUnion(polys, z) {
wasm_ctrl.count.union++;
let wasm = base.wasm,
buffer = wasm.shared,
pcount = writePolys(new DataWriter(wasm.heap, buffer), polys),
resat = wasm.fn.union(buffer, pcount),
out = readPolys(new DataReader(wasm.heap, resat), z);
return polyNest(out);
}
export function polyDiff(polysA, polysB, z, AB, BA) {
wasm_ctrl.count.diff++;
let wasm = base.wasm,
buffer = wasm.shared,
writer = new DataWriter(wasm.heap, buffer),
pcountA = writePolys(writer, polysA),
pcountB = writePolys(writer, polysB),
resat = wasm.fn.diff(buffer, pcountA, pcountB, AB?1:0, BA?1:0, config.clipperClean),
reader = new DataReader(wasm.heap, resat);
if (AB) {
AB.appendAll(polyNest(readPolys(reader, z)));
}
if (BA) {
BA.appendAll(polyNest(readPolys(reader, z)));
}
}
// nest closed polygons without existing parent / child relationships
function polyNest(polys) {
polys.sort((a,b) => {
return b.bounds.minx - a.bounds.minx;
});
// from smallest to largest, check for enclosing bounds and nest
for (let i=0, il=polys.length; i<il; i++) {
let smaller = polys[i];
// prevent parent poly from being consumed
if (smaller.inner) continue;
for (let j=i+1; j<il; j++) {
let larger = polys[j];
if (larger.bounds.contains(smaller.bounds)) {
larger.addInner(smaller);
break;
}
}
}
let tops = [];
for (let i=0, il=polys.length; i<il; i++) {
let poly = polys[i];
if (!poly.parent) {
tops.push(poly);
}
}
return tops;
}
function readString(pos, len) {
let view = new DataReader(base.wasm.heap, pos);
let out = [];
while (len-- > 0) {
out.push(String.fromCharCode(view.readU8()));
}
return out.join('');
}
export function enable() {
if (base.wasm || base._wasm) {
return;
}
base._wasm = 'loading';
fetch('/wasm/kiri-geo.wasm')
.then(response => response.arrayBuffer())
.then(bytes => WebAssembly.instantiate(bytes, {
env: {
debug_string: (len, ptr) => { console.log('wasm', readString(ptr, len)) }
},
wasi_snapshot_preview1: {
// args_get: (count,bufsize) => { return 0 },
// args_sizes_get: (count,bufsize) => { },
// environ_get: (count,bufsize) => { return 0 },
// environ_sizes_get: (count,bufsize) => { },
proc_exit: (code) => { return code }
}
}))
.then(results => {
// console.log({enabled: base.wasm});
delete base._wasm;
let { module, instance } = results;
let { exports } = instance;
let heap = new DataView(exports.memory.buffer);
let wasm = base.wasm = {
heap,
exports,
memory: exports.memory,
memmax: exports.memory.buffer.byteLength,
malloc: exports.mem_get,
free: exports.mem_clr
};
wasm.shared = wasm.malloc(1024 * 1024 * 30),
wasm.fn = {
diff: exports.poly_diff,
union: exports.poly_union,
offset: exports.poly_offset
};
wasm.js = {
diff: polyDiff,
union: polyUnion,
offset: polyOffset
};
});
}
export function disable() {
if (base.wasm) {
base.wasm.free(base.wasm.shared);
delete base.wasm;
}
}

232
src2/load/3mf.js Normal file
View file

@ -0,0 +1,232 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
'use strict';
import { THREE } from '../ext/three.js';
import * as JSZip from '../ext/jszip.js';
let { BufferAttribute, Matrix4 } = THREE;
let scaleMap = {
"inch": (1 / 25.4),
"foot": (1 / 304.8),
"micron": (1 / 1000),
"meter": 1000,
"millimeter": 1,
"centimeter": (1 / 10)
};
export function query(node, path, fn) {
let collect = {};
let match = path[0].split('|').map(key => {
if (key[0] === '+') {
key = key.slice(1);
collect[key] = true;
}
return key;
});
for (let child of [...node.childNodes]) {
let { tagName } = child;
if (match.indexOf(tagName) >= 0) {
if (collect[tagName]) {
fn(tagName, child);
}
if (path.length > 1) {
query(child, path.slice(1), fn);
}
}
}
}
function transform(def, mesh) {
let pos = new BufferAttribute(mesh.toFloat32(), 3);
let mat = def.split(' ').map(v => parseFloat(v));
mat = [
...mat.slice(0,3), 0,
...mat.slice(3,6), 0,
...mat.slice(6,9), 0,
...mat.slice(9,12), 1
];
pos.applyMatrix4(new Matrix4().fromArray(mat));
return pos.array;
}
/** find matching attribute regardless of namespace */
function getLocalAttribute(node, name) {
for (let attr of node.attributes) {
if (attr.localName === name) {
return attr.value;
}
}
return undefined;
}
function loadModel(doc) {
let items = [];
let objects = {};
return new Promise((resolve, reject) => {
let scale = 1;
query(doc, ["+model","resources","+object"], (type, node) => {
switch (type) {
case "model":
let units = node.getAttribute("unit");
if (units) {
scale = scaleMap[units] || 1;
}
query(node, ["build","+item"], (type, node) => {
items.push({
oid: node.getAttribute('objectid'),
xform: node.getAttribute('transform')
});
});
break;
case "object":
let object = {
name: node.getAttribute("name")
};
objects[node.getAttribute("id")] = object;
// object are allowed one mest or one or more components
query(node, ["+mesh"], (type, node) => {
let vertices = [];
query(node, ["vertices","+vertex"], (type, vertex) => {
vertices.push([
parseFloat(vertex.getAttribute("x")) * scale,
parseFloat(vertex.getAttribute("y")) * scale,
parseFloat(vertex.getAttribute("z")) * scale
]);
});
let mesh = object.mesh = [];
query(node, ["triangles","+triangle"], (type, triangle) => {
let v1 = parseInt(triangle.getAttribute("v1"));
let v2 = parseInt(triangle.getAttribute("v2"));
let v3 = parseInt(triangle.getAttribute("v3"));
mesh.appendAll(vertices[v1]);
mesh.appendAll(vertices[v2]);
mesh.appendAll(vertices[v3]);
});
});
if (object.mesh) {
return;
} else {
object.components = [];
}
query(node, ["components","+component"], (type, node) => {
object.components.push({
oid: node.getAttribute('objectid'),
path: getLocalAttribute(node, "path"),
xform: node.getAttribute('transform'),
});
});
break;
}
});
return resolve({ objects, items });
// create object mesh from components
for (let object of Object.values(objects)) {
let { mesh, components } = object;
if (mesh) {
continue;
}
mesh = object.mesh = [];
for (let component of components) {
let { oid, xform } = component;
let ref = objects[oid];
if (!ref) {
console.log({ missing_ref: oid, objects, components, doc });
} else if (xform) {
mesh.appendAll(transform(xform, ref.mesh));
} else {
mesh.appendAll(ref.mesh);
}
}
}
// create export items from object references
for (let item of items) {
let { oid, xform } = item;
let { name, mesh } = objects[oid];
item.name = name;
if (xform) {
item.faces = transform(xform, mesh);
} else {
item.faces = mesh;
}
}
// return array: [{ name, faces }, { name, faces }]
resolve(items);
});
}
function extractItems(records) {
let outItems = [];
let models = Object.values(records);
// create object mesh from components
for (let model of models) {
let { objects } = model;
for (let object of Object.values(objects)) {
let { mesh, components } = object;
if (mesh) {
continue;
}
mesh = object.mesh = [];
for (let component of components) {
let { oid, path, xform } = component;
let omap = objects;
if (path) {
omap = records[path.substring(1)].objects;
// console.log({ component_from_ob_path: path, using: omap });
}
let ref = omap[oid];
if (!ref) {
console.log({ missing_ref: oid, objects, components });
} else if (xform) {
mesh.appendAll(transform(xform, ref.mesh));
} else {
mesh.appendAll(ref.mesh);
}
}
}
}
// create export items from object references
for (let model of models) {
let { items, objects } = model;
for (let item of items || []) {
let { oid, xform } = item;
let { name, mesh } = objects[oid];
item.name = name;
if (xform) {
item.faces = transform(xform, mesh);
} else {
item.faces = mesh;
}
}
outItems.push(...items);
}
return outItems;
}
/**
* @param {Object} data binary file
* @returns {Array} vertex face array
*/
export function parseAsync(data) {
return new Promise(async (resolve, reject) => {
let zip = await JSZip.default.loadAsync(data);
let models = {};
for (let [key, value] of Object.entries(zip.files)) {
if (key.endsWith(".model")) {
let xml = await value.async("string");
let { objects, items } = await loadModel(new DOMParser().parseFromString(xml, "text/xml"));
models[key] = { objects, items };
}
}
resolve(extractItems(models));
});
}

104
src2/load/file.js Normal file
View file

@ -0,0 +1,104 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
'use strict';
import * as STL from './stl.js';
import * as OBJ from './obj.js';
import * as TMF from './3mf.js';
import * as SVG from './svg.js';
import * as GBR from './gbr.js';
import { load as pngLoad } from './png.js';
const types = {
stl(data, file, resolve, reject, opt = {}) {
resolve([{
mesh: new STL().parse(data), file
}]);
},
obj(data, file, resolve, reject, opt = {}) {
resolve(OBJ.parse(data).map(m => {
return { mesh: m.toFloat32(), file: nameOf(file, m.name, 1) }
}));
},
"3mf"(data, file, resolve, reject, opt = {}) {
let i = 1;
TMF.parseAsync(data).then((meshes) => {
resolve(meshes.map(m => {
return { mesh: m.faces.toFloat32(), file: nameOf(file, m.name, i++) }
}));
});
},
svg(data, file, resolve, reject, opt = {}) {
let out = SVG.parse(data, opt);
resolve(opt.flat ? out : out.map(m => { return { mesh: m.toFloat32(), file } }));
},
png(data, file, resolve, reject, opt = {}) {
pngLoad.PNG.parse(data, {
...opt,
done(vertices) { resolve(vertices) },
error(err) { reject(err) }
});
},
gbr(data, file, resolve, reject, opt = {}) {
if (opt.flat) {
resolve([ GBR.parse(data) ])
} else {
resolve([{ mesh: GBR.toMesh(data).toFloat32(), file }])
}
}
};
const as_buffer = [ "stl", "png", "3mf" ];
function nameOf(file, part, i) {
let lid = file.lastIndexOf('.');
if (!part && lid > 0) {
file = file.substring(0,lid);
}
return part ? part : `${file}_${i}`;
}
function load_data(data, file, ext, opt = {}) {
ext = ext || file.name.toLowerCase().split('.').pop();
return new Promise((resolve, reject) => {
let fn = types[ext];
if (fn) {
fn(data, file, resolve, reject, opt);
} else {
reject(`unknown file type: "${ext}" from ${file}`);
}
});
}
function load_file(file, opt) {
if (Array.isArray(file)) {
return Promise.all(file.map(file => load_file(file, opt)));
}
return new Promise((resolve, reject) => {
if (!file) {
return reject('invalid or missing file');
}
let reader = new FileReader();
let name = file.name;
let ext = name.toLowerCase().split('.').pop();
reader.file = file;
reader.onloadend = function (event) {
load_data(event.target.result, name, ext, opt)
.then(data => resolve(data))
.catch(e => reject(e));
};
if (as_buffer.indexOf(ext) >= 0) {
reader.readAsArrayBuffer(reader.file);
} else {
reader.readAsBinaryString(reader.file);
}
});
}
export { types, as_buffer, load_data, load_file };
export { load_file as load };

227
src2/load/gbr.js Normal file
View file

@ -0,0 +1,227 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { polygons } from '../geo/polygons.js';
import { CSG } from '../geo/csg.js';
import * as TracespaceParser from '../ext/gerber.js';
import { newPolygon } from '../geo/polygon.js';
import { newBounds } from '../geo/bounds.js';
export function toMesh(text, opt = {}) {
const progress = opt.progress || function(){};
const debug = opt.debug ? console.log : function(){};
// for now ignore open trace lines
// future exports should allow a way to represent them for cnc
const { open, closed, circs, rects } = parse(text);
progress(5);
// for the copper layer:
// should produce one top level poly which is the board outline
// next level down should be the remaining copper trace which we
// subtract from the board to produce paths that can be milled
const nest = polygons.nest([...closed, ...circs, ...rects], true);
debug({ nest });
if (nest.length === 0) return [].toFloat32();
const flat = [];
for (let n of nest) n.flattenTo(flat, [], true);
progress(8);
debug({ flat });
const z0 = opt.z || opt.z0 || 1;
const z1 = opt.z1 || 0.25;
// encapsulate all polys in one enclosing rect with an offset
if (nest.length > 1) {
const offset = (opt.offset || 2) * 2;
const bounds = nest[0].bounds;
for (let p of nest) {
bounds.merge(p.bounds);
}
const rect = newPolygon().centerRectangle(bounds.center(0), bounds.width() + offset, bounds.height() + offset);
for (let n of nest) {
n.depth++;
}
flat.push(rect);
debug({ bounds, flat });
opt.raise = z1;
}
// board outline
const top = [];
for (let poly of flat.filter(p => p.depth === 0)) {
top.push(poly.extrude(z0));
}
progress(15);
debug({ top });
// nothing to render
if (top.length === 0) {
return [].toFloat32();
}
// copper pads
const mid = [];
for (let poly of flat.filter(p => p.depth === 1)) {
mid.push(poly.extrude(z1, { zadd: z0 - z1 }));
}
progress(20);
debug({ mid });
// if no traces, return board
if (mid.length === 0) {
return top[0];
}
// copper traces (expand first since open)
const open_exp = open.map(poly => {
const diam = poly.tool?.shape?.diameter;
return diam ? poly.offset_open(diam / 2, 'round') : null;
}).filter(p => p).flat();
// union open trace expansions
const opn = [];
for (let poly of polygons.union(open_exp, 0, true)) {
const tool = poly.tool;
opn.push(poly.extrude(z0 + (opt.raise || 0)));
}
progress(25);
debug({ opn });
// inner polys for traces which we add back to the top
// and (optionally) remove from the bottom for making vias
const low = [];
for (let poly of flat.filter(p => p.depth === 2)) {
low.push(poly.extrude(z1, { zadd: z0 - z1 }));
}
progress(30);
// convert vertex arrays to csg mesh definitions
const topMesh = CSG.fromPositionArray(top[0]);
const midMesh = mid.map(v => CSG.fromPositionArray(v));
progress(35);
// subtract mid from top
let meshOut = CSG.subtract(topMesh, ...midMesh);
progress(50);
if (opn.length) {
// add back raised traces (before removing vias)
const opnMesh = opn.map(v => CSG.fromPositionArray(v))
meshOut = CSG.union(meshOut, ...opnMesh);
}
progress(70);
if (low.length) {
// subtract low (vias) from remaining
const lowMesh = low.map(v => CSG.fromPositionArray(v))
meshOut = CSG.union(meshOut, ...lowMesh);
}
progress(90);
return CSG.toPositionArray(meshOut);
}
export function parse(text) {
let fact = 1000000;
let tools = {};
let tool;
let poly;
let polys = [];
let circs = [];
let rects = [];
let last = {};
let bounds = newBounds();
let p = TracespaceParser.createParser();
p.feed(text);
let r = p.results();
let c = r.children
.filter(c => {
switch (c.type) {
case 'toolDefinition':
tools[c.code] = c.shape;
break;
case 'toolChange':
tool = { code: c.code, shape: tools[c.code] };
break;
case 'graphic':
c.tool = tool;
return true;
}
return false;
})
.map(r => {
return {
type: r.graphic,
tool: r.tool,
x: parseInt(r.coordinates.x),
y: parseInt(r.coordinates.y)
};
})
.map(r => {
const { x, y, tool, type } = r;
const pos = { x:x/fact, y:y/fact, z: 0};
bounds.update(pos);
switch (type) {
case 'move':
if (true && poly && last.x === x && last.y === y && last.tool === tool) {
// console.log('continuation');
} else {
poly = newPolygon().setOpen().add(pos.x, pos.y, 0);
poly.tool = tool;
polys.push(poly);
}
break;
case 'segment':
poly.add(pos.x, pos.y, 0);
break;
case 'shape':
poly = undefined;
let { shape } = tool;
switch (shape.type) {
case 'obround':
if (shape.xSize === shape.ySize) {
circs.push(newPolygon().centerCircle(pos, shape.xSize / 2, 20));
} else {
console.log('TODO', shape);
}
break;
case 'circle':
circs.push(newPolygon().centerCircle(pos, shape.diameter / 2, 20));
break;
case 'rectangle':
rects.push(newPolygon().centerRectangle(pos, shape.xSize, shape.ySize));
break;
default:
console.log('TODO', shape);
break;
}
break;
}
last = { x, y, tool };
return r;
});
// center board on origin
const { minx, maxx, miny, maxy } = bounds;
for (let poly of [...polys, ...circs, ...rects]) {
poly.move({
x: -((maxx-minx)/2 + minx),
y: -((maxy-miny)/2 + miny),
z: 0
});
}
// separate open and closed polys (traces vs areas)
const open = [];
const closed = [];
for (let poly of polys) {
if (poly.appearsClosed()) {
// clean up gnarly overlapping lines
// make contiguous, simpler enclosed areas
closed.push(...poly.simplify());
} else {
open.push(poly);
}
}
return { open, closed, circs, rects };
}

70
src2/load/obj.js Normal file
View file

@ -0,0 +1,70 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
'use strict';
import { util } from '../geo/base.js';
/**
* @param {String} text
* @returns {Array} vertex face array
*/
export function parseAsync(text) {
return new Promise((resolve,reject) => {
resolve(parse(text));
});
}
export function parse(text) {
let lines = text.replaceAll(/ +/g,' ').split('\n').map(l => l.trim());
let verts = [];
let faces = [];
let objs = [ faces ];
// Polyfill for appendAll if not present
if (!Array.prototype.appendAll) {
Object.defineProperty(Array.prototype, 'appendAll', {
value: function(arr) {
for (let v of arr) this.push(v);
},
enumerable: false
});
}
for (let line of lines) {
let toks = line.split(' ');
switch (toks.shift()) {
case 'v':
let v = toks.map(v => parseFloat(v)).slice(0,3);
if (v.length < 3) {
console.log('??',toks,line);
}
verts.push(v);
break;
case 'f':
let tok = toks.map(f => parseInt(f.split('/')[0]));
if (tok.length > 3) {
const p = tok.map(t => verts[t-1]).flat();
const t = util.triangulate(p, [], 3, true);
const r = t.map(i => tok[i]);
for (let v of r) {
faces.appendAll(verts[v - 1]);
}
} else {
// add support for negative indices (offset from last vertex array point)
faces.appendAll(verts[tok[0]-1]);
faces.appendAll(verts[tok[1]-1]);
faces.appendAll(verts[tok[2]-1]);
}
break;
case 'g':
if (faces.length) {
objs.push(faces = []);
}
if (toks[0]) {
faces.name = toks.join(' ');
}
break;
}
}
return objs;
}

31
src2/load/png.js Normal file
View file

@ -0,0 +1,31 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { PNG } from '../ext/pngjs.esm.js';
export const load = {
PNG: {
parse: function(data, opt = {}) {
let img = new PNG();
let progress = opt.progress || noop;
let ondone = opt.done || noop;
let onerror = opt.error || noop;
let onmeta = opt.meta || noop;
img.on('metadata', function(meta) {
onmeta(meta);
});
img.on('parsed', function(data) {
ondone(data);
});
img.on('error', function(err) {
onerror(err);
});
img.parse(data);
}
}
};
function noop() {}

250
src2/load/stl.js Normal file
View file

@ -0,0 +1,250 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
'use strict';
// Polyfill for appendAll if not present
if (!Array.prototype.appendAll) {
Object.defineProperty(Array.prototype, 'appendAll', {
value: function(arr) {
for (let v of arr) this.push(v);
},
enumerable: false
});
}
export class STL {
constructor() {
this.vertices = null;
this.normals = null;
this.colors = null;
}
load(url, callback, formdata, scale, credentials, headers) {
const stl = this;
fetch(url, {
method: formdata ? 'POST' : 'GET',
credentials: credentials ? credentials : 'same-origin',
body: formdata,
...(headers ? {headers: headers} : {})
}).then(response => {
if (response.status === 200 || response.status === 0) {
response.arrayBuffer().then(buffer => {
stl.parse(buffer, scale);
let cdhVal = response.headers.get(CDH);
if (typeof cdhVal === "string") {
cdhVal = cdhVal.split(';').map(v => v.trim()).filter(v => {
return v.indexOf('filename=') === 0;
}).map(v => {
return v.substring(10,v.length-1);
})[0];
}
if (callback) callback(stl.vertices, cdhVal);
});
} else {
if (callback) callback(null, response.statusText);
}
}).catch(err => {
if (callback) callback(null, err)
});
}
encode(vertices, normals) {
if (!(vertices && vertices.length % 3 === 0)) throw "invalid vertices";
let vc = vertices.length / 3,
bs = (vc * 16) + (vc * (2/3)) + 84,
bin = new ArrayBuffer(bs),
writer = new DataView(bin),
i = 0,
j = 0,
pos = 80;
function writeInt16(val) {
writer.setUint16(pos, val, true);
pos += 2;
}
function writeInt32(val) {
writer.setUint32(pos, val, true);
pos += 4;
}
function writeFloat(val) {
writer.setFloat32(pos, val, true);
pos += 4;
}
function writeVertex() {
writeFloat(vertices[i++]); // x
writeFloat(vertices[i++]); // y
writeFloat(vertices[i++]); // z
}
writeInt32(vc / 3);
while (i < vertices.length) {
writeFloat(normals ? normals[j++] : 0); // norm x
writeFloat(normals ? normals[j++] : 0); // norm y
writeFloat(normals ? normals[j++] : 0); // norm z
writeVertex(); // p1
writeVertex(); // p2
writeVertex(); // p3
writeInt16(0); // attributes
}
return bin;
}
parse(data, scale) {
let binData = this.convertToBinary(data);
let isBinary = function () {
let expect, face_size, n_faces, reader;
reader = new DataView(binData);
face_size = (32 / 8 * 3) + ((32 / 8 * 3) * 3) + (16 / 8);
n_faces = reader.getUint32(80,true);
expect = 80 + (32 / 8) + (n_faces * face_size);
return expect === reader.byteLength;
};
return isBinary()
? this.parseBinary(binData, scale)
: this.parseASCII(this.convertToString(data), scale);
}
parseAsync(data, scale) {
return new Promise((resolve, reject) => {
resolve(this.parse(data, scale));
});
}
parseBinary(data, scale = 1) {
let reader = new DataView(data),
faces = reader.getUint32 (80, true),
r, g, b, hasColors = false, colors,
defaultR, defaultG, defaultB, alpha;
// check for default color in STL header ("COLOR=rgba" sequence).
for (let index = 0; index < 80 - 10; index++) {
if ((reader.getUint32(index, false) == 0x434F4C4F /*COLO*/) &&
(reader.getUint8(index + 4) == 0x52 /*'R'*/) &&
(reader.getUint8(index + 5) == 0x3D /*'='*/)) {
hasColors = true;
colors = new Float32Array(faces * 3 * 3);
defaultR = reader.getUint8(index + 6) / 255;
defaultG = reader.getUint8(index + 7) / 255;
defaultB = reader.getUint8(index + 8) / 255;
alpha = reader.getUint8(index + 9) / 255;
}
}
let offset = 0,
dataOffset = 84,
faceLength = 12 * 4 + 2,
vertices = new Float32Array(faces * 3 * 3),
normals = new Float32Array(faces * 3 * 3);
colors = hasColors ? new Uint16Array(faces * 3 * 3) : null;
for (let face = 0; face < faces; face ++) {
let start = dataOffset + face * faceLength,
normalX = reader.getFloat32(start, true),
normalY = reader.getFloat32(start + 4, true),
normalZ = reader.getFloat32(start + 8, true);
if (hasColors) {
let packedColor = reader.getUint16(start + 48, true);
if ((packedColor & 0x8000) === 0) { // facet has its own unique color
r = (packedColor & 0x1F) / 31;
g = ((packedColor >> 5) & 0x1F) / 31;
b = ((packedColor >> 10) & 0x1F) / 31;
} else {
r = defaultR;
g = defaultG;
b = defaultB;
}
}
let i = 1, vertexstart;
while (i <= 3) {
vertexstart = start + (i++) * 12;
vertices[offset ] = reader.getFloat32 (vertexstart, true) * scale;
vertices[offset + 1] = reader.getFloat32 (vertexstart + 4, true) * scale;
vertices[offset + 2] = reader.getFloat32 (vertexstart + 8, true) * scale;
normals[offset ] = normalX;
normals[offset + 1] = normalY;
normals[offset + 2] = normalZ;
if (hasColors) {
colors[offset ] = r;
colors[offset + 1] = g;
colors[offset + 2] = b;
}
offset += 3;
}
}
this.vertices = vertices;
this.normals = normals;
this.colors = colors;
return vertices;
}
parseASCII(data, scale = 1) {
let result,
resultText,
patternNormal,
patternVertex,
vertices = [],
normals = [],
patternFace = /facet([\s\S]*?)endfacet/g;
while ((result = patternFace.exec(data)) !== null) {
resultText = result[0];
patternNormal = /normal[\s]+([\-+]?[0-9]+\.?[0-9]*([eE][\-+]?[0-9]+)?)+[\s]+([\-+]?[0-9]*\.?[0-9]+([eE][\-+]?[0-9]+)?)+[\s]+([\-+]?[0-9]*\.?[0-9]+([eE][\-+]?[0-9]+)?)+/g;
patternVertex = /vertex[\s]+([\-+]?[0-9]+\.?[0-9]*([eE][\-+]?[0-9]+)?)+[\s]+([\-+]?[0-9]*\.?[0-9]+([eE][\-+]?[0-9]+)?)+[\s]+([\-+]?[0-9]*\.?[0-9]+([eE][\-+]?[0-9]+)?)+/g;
while ((result = patternNormal.exec(resultText)) !== null) {
normals.push(parseFloat(result[1]));
normals.push(parseFloat(result[3]));
normals.push(parseFloat(result[5]));
}
while ((result = patternVertex.exec(resultText)) !== null) {
vertices.push(parseFloat(result[1]) * scale);
vertices.push(parseFloat(result[3]) * scale);
vertices.push(parseFloat(result[5]) * scale);
}
}
let vToFloat32 = new Float32Array(vertices.length),
nToFloat32 = new Float32Array(normals.length),
i;
for (i=0; i<vertices.length; i++) vToFloat32[i] = vertices[i];
for (i=0; i<normals.length; i++) nToFloat32[i] = normals[i];
this.vertices = vToFloat32;
this.normals = nToFloat32;
return vToFloat32;
}
convertToString(buf) {
if (typeof buf !== "string") {
let array_buffer = new Uint8Array(buf);
let str = '';
for (let i = 0; i < buf.byteLength; i++) {
str += String.fromCharCode(array_buffer[i]);
}
return str;
} else {
return buf;
}
}
convertToBinary(buf) {
if (typeof buf === "string") {
let array_buffer = new Uint8Array(buf.length);
for (let i = 0; i < buf.length; i++) {
array_buffer[i] = buf.charCodeAt(i) & 0xff;
}
return array_buffer.buffer || array_buffer;
} else {
return buf;
}
}
}

78
src2/load/svg.js Normal file
View file

@ -0,0 +1,78 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { newPolygon } from '../geo/polygon.js';
import { polygons } from '../geo/polygons.js';
import { THREE } from '../ext/three.js';
export function parseAsync(text, opt) {
return new Promise((resolve,reject) => {
resolve(parse(text, opt));
});
}
export function parse(text, opt = { }) {
const justPoly = opt.flat || false;
const fromSoup = opt.soup !== false || justPoly;
const rez = (opt.resolution || 1);
const dpi = (opt.dpi || 0);
const segmin = Math.max(1, opt.segmin || 10);
const data = new THREE.SVGLoader().parse(text);
const paths = data.paths;
const xmlat = data.xml.attributes;
const objs = [];
const polys = [];
const isinch = xmlat.width?.value.endsWith('in');
const scale = isinch ? 25.4 : (dpi ? 1 / (dpi / 25.4) : 1);
const depth = parseFloat(opt.depth || xmlat['data-km-extrude']?.value
|| xmlat['extrude']?.value
|| 5);
for (let i = 0; i < paths.length; i++) {
let path = paths[i];
let shapes = path.toShapes(true);
let type = path.userData?.node?.nodeName;
let width = path.userData?.style?.strokeWidth;
let miter = path.userData?.style?.strokeMiterLimit;
for (let sub of path.subPaths) {
let points = sub.curves.map(curve => {
let length = curve.getLength();
let segs = curve.type === 'LineCurve' ?
1 : Math.max(Math.ceil(length * rez), segmin);
return curve.getPoints(segs);
}).flat();
if (points.length < 3) {
continue;
}
let poly = newPolygon().addPoints(points.map(p => newPolygon.newPoint(p.x, -p.y, 0)));
if (poly.appearsClosed()) poly.points.pop();
if (type === 'polyline') poly.setOpen(true);
poly._svg = { width, miter };
polys.push(poly);
if (scale !== 1) {
poly.scale({ x: scale, y: scale, z: 1 });
}
}
}
const sub = fromSoup ? polygons.nest(polys) : polys;
const nest = sub.filter(p => {
for (let pc of polys) {
if (pc === p) {
return true;
} else {
return !pc.isEquivalent(p);
}
}
});
if (justPoly) {
return nest;
}
for (let poly of nest) {
let obj = poly.extrude(depth);
objs.push(obj);
}
return objs;
}

81
src2/load/url.js Normal file
View file

@ -0,0 +1,81 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
'use strict';
import { STL } from './stl.js';
import { OBJ } from './obj.js';
import { TMF } from './3mf.js';
import { SVG } from './svg.js';
const CDH = 'Content-Disposition';
export function load_url(url, options = {}) {
return new Promise((resolve, reject) => {
let xhr = new XMLHttpRequest();
let file = options.file || options.filename || (((url.split('?')[0]).split('#')[0]).split('/')).pop();
let ext = file.split('.').pop().toLowerCase();
let deftype = ext === "obj" || ext === 'svg' ? "text" : "arraybuffer";
let datatype = options.datatype || deftype;
let formdata = options.formdata;
function onloaded(event) {
if (event.target.status === 200 || event.target.status === 0) {
if (xhr.getAllResponseHeaders().indexOf(CDH) > 0) {
// attempt to extract filename from content disposition
let fname = xhr.getResponseHeader(CDH)
.split(';').map(v => v.trim()).filter(v => {
return v.indexOf('filename=') === 0;
}).map(v => {
return v.substring(10,v.length-1);
})[0];
if (fname) {
file = fname;
ext = file.split('.').pop().toLowerCase();
}
}
let data = event.target.response || event.target.responseText;
// return raw data if indicated
if (options.parse === false) {
return resolve(data, ext);
}
switch (ext) {
case "stl":
resolve([{
mesh: new STL().parse(data), file
}]);
break;
case "obj":
resolve(OBJ.parse(data).map(m => { return {mesh: m.toFloat32(), file} }));
break;
case "3mf":
TMF.parseAsync(data).then((meshes) => {
resolve(meshes.map(m => {
return { mesh: m.faces.toFloat32(), file }
}));
});
break;
case "svg":
resolve(SVG.parse(data).map(m => { return {mesh: m.toFloat32(), file} }));
break;
default:
reject(`unknown file type: "${ext}" from ${url}`);
break;
}
} else {
reject(event.target.statusText);
}
}
xhr.addEventListener('load', onloaded, false);
xhr.addEventListener('progress', function (event) { }, false);
xhr.addEventListener('error', function () { }, false);
if (xhr.overrideMimeType) {
xhr.overrideMimeType('text/plain; charset=x-user-defined');
}
xhr.open(formdata ? 'POST' : 'GET', url, true);
xhr.responseType = datatype;
xhr.send(formdata);
});
}

775
src2/main/mesh.js Normal file
View file

@ -0,0 +1,775 @@
/** 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 { 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 { load as fileLoad } from '../load/file.js';
import { $, $d, estop } from '../moto/webui.js';
import { THREE } from '../ext/three.js';
const { Quaternion } = THREE;
const version = '1.5.6';
const api = meshApi;
const call = motoBroker.send;
const dbindex = [ "admin", "space" ];
function log() {
return meshApi.log.emit(...arguments);
}
// set below. called once the DOM readyState = complete
// this is the main() entrypoint called after all dependents load
function init() {
let stores = dataOpen('mesh', { stores: dbindex, version: 4 }).init(),
dark = false,
ortho = false,
zoomrev = true,
zoomspd = 1,
platform = motoSpace.platform,
db = meshApi.db = {
admin: stores.promise('admin'),
space: stores.promise('space')
};
// 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({
grid: false,
color: dark ? 0 : 0xffffff
});
platform.set({
volume: false,
round: false,
zOffset: 0,
opacity: 0,
color: 0xdddddd,
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 },
});
platform.onMove(() => {
// save last location and focus
db.admin.put('camera', {
place: motoSpace.view.save(),
focus: motoSpace.view.getFocus()
});
}, 100);
motoSpace.view.setZoom(zoomrev, zoomspd);
// reload stored space when worker is ready
motoClient.on('ready', restore_space);
// start worker
motoClient.start(`/code/mesh_work?${version}`);
// trigger space event binding
call.space_init({ space: motoSpace, platform });
// trigger ui building
call.ui_build();
}
// 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;
// let mcache = {};
await db_admin.get("camera")
.then(saved => {
if (saved) {
space.view.load(saved.place);
space.view.setFocus(saved.focus);
}
});
const mcache = await db_admin.get("meta") || {};
let count = 0;
await db_space.iterate({ map: true }).then(cached => {
const keys = [];
const claimed = [];
for (let [id, data] of Object.entries(cached)) {
// console.log({ id, data });
keys.push(id);
if (count++ === 0) {
log(`restoring workspace`);
}
// restore object based on type
// group arrays load models they contain
// sketches are loaded by type since they're not grouped
if (Array.isArray(data)) {
claimed.push(id);
let models = data
.map(id => {
claimed.push(id);
return { id, md: cached[id] }
})
.filter(r => r.md) // filter cache misses
.map(r => new meshModel(r.md, r.id).applyMeta(mcache[r.id]))
if (models.length) {
log(`restored ${models.length} model(s)`);
api.group.new(models, id).applyMeta(mcache[id])
} else {
log(`removed empty group ${id}`);
db_space.remove(id);
}
} else if (data.type === 'sketch') {
claimed.push(id);
api.add.sketch({ id, ...data });
}
}
for (let id of claimed) {
keys.remove(id);
}
if (keys.length) {
log(`removing ${keys.length} unclaimed meshes`);
}
// clear out meshes left in the space db along with their meta-data
for (let id of keys) {
db_space.remove(id);
delete mcache[id];
}
// restore global cache only after objects are restored
// otherwise their setup will corrupt the cache for other restores
metaCache = mcache;
store_meta();
}).then(() => {
// restore preferences after models are restored
return api.prefs.load().then(() => {
let { map } = api.prefs;
let { space, mode } = map;
api.grid(space.grid);
// restore selected state
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]);
// restore edit mode
api.mode[mode]();
// restore dark mode
set_darkmode(map.space.dark);
});
}).finally(() => {
// hide loading curtain
$d('curtain','none');
if (api.prefs.map.info.welcome !== false) {
api.welcome(version);
}
});
}
// add space event bindings
function space_init(data) {
let platcolor = 0x00ff00;
let { space, platform } = data;
let { selection } = meshApi;
// add file drop handler
space.event.addHandlers(self, [
'drop', (evt) => {
estop(evt);
platform.set({ opacity: 0, color: platcolor });
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 });
},
'keypress', evt => {
if (api.modal.showing) {
return;
}
if (evt.key === '?') {
return api.welcome(version);
}
let { shiftKey, metaKey, ctrlKey, code } = evt;
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 'KeyQ':
return api.settings();
case 'KeyI':
return shiftKey ? api.tool.invert() : api.file.import();
case 'KeyX':
return api.file.export();
case 'KeyD':
return shiftKey && api.tool.duplicate();
case 'KeyC':
return selection.centerXY().focus();
case 'KeyF':
return shiftKey ? selection.focus() : selection.floor().focus();
case 'KeyM':
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);
return api.sketch.extrude();
}
return;
case 'KeyV':
return shiftKey ? selection.show() : selection.focus();
case 'KeyN':
return shiftKey ? estop(evt, api.tool.rename()) : api.normals();
case 'KeyW':
return api.wireframe();
case 'KeyG':
return shiftKey ?
(api.mode.is([ api.modes.sketch ]) ? api.sketch.arrange.group() : api.tool.regroup()) :
api.grid();
case 'KeyL':
return api.log.toggle({ spinner: false });
case 'KeyS':
if (!api.mode.is([ api.modes.object ])) return;
return shiftKey ? selection.visible({toggle:true}) : meshSplit.start();
case 'KeyB':
return selection.boundsBox({toggle:true});
case 'KeyH':
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 => {
let { shiftKey, metaKey, ctrlKey, code } = evt;
let once = keyOnce[code];
if (once) {
delete keyOnce[code];
return once(evt);
}
let rv = (Math.PI / 12);
if (api.modal.showing) {
if (code === 'Escape') {
api.modal.cancel();
}
return;
}
let rot, floor = api.prefs.map.space.floor !== false;
switch (code) {
case 'KeyA':
if (metaKey || ctrlKey) {
if (!selection.sketch()?.selection.all()) {
api.mode.object();
selection.set(api.group.list());
}
estop(evt);
}
break;
case 'Escape':
if (selection.clear()) {
meshEdges.clear();
meshSplit.end();
}
estop(evt);
break;
case 'Backspace':
case 'Delete':
let mode = api.mode.get();
if ([api.modes.object, api.modes.tool, api.modes.sketch].contains(mode)) {
selection.delete();
} else {
for (let m of api.model.list()) {
m.deleteSelections();
space.refresh()
}
}
estop(evt);
break;
case 'ArrowUp':
rot = selection.rotate(-rv,0,0);
break;
case 'ArrowDown':
rot = selection.rotate(rv,0,0);
break;
case 'ArrowLeft':
if (shiftKey) {
rot = selection.rotate(0,-rv,0);
} else {
floor = false;
rot = selection.rotate(0,0,rv);
}
break;
case 'ArrowRight':
if (shiftKey) {
rot = selection.rotate(0,rv,0);
} else {
floor = false;
rot = selection.rotate(0,0,-rv);
}
break;
}
if (rot && floor) {
rot.floor();
}
}
]);
// mouse hover/click handlers. required to enable model drag in space.js
// 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) => {
let obj = int?.object;
if (obj) api.selection.drag({ start: int.object });
if (obj) obj.sketch_item?.sketch.drag({ start: int.object });
if (event?.shiftKey) {
return api.objects();
} else {
return undefined;
}
});
// 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
let els = ints.filter(i => i.object.sketch_item);
int = els.length ? els[0] : int;
}
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;
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;
// y,z swap due to world rotation for orbit controls
api.focus({center: { x, y:-z, z:y }, normal});
let one = api.sketch.selected.one;
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) {
// rotate selected face towawrd z "floor"
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) {
case modes.sketch:
api.mode.object();
case modes.object:
case modes.tool:
selection.toggle(shiftKey ? model : model.group, mode === modes.tool);
break;
case modes.surface:
opt.radians = surface.radians;
case modes.face:
// find faces adjacent to point/line clicked
model.find(int,
altKey ? { toggle: true } :
shiftKey ? { clear: true } : { select: true },
opt);
break;
case modes.edge:
meshEdges.select();
break;
}
}
} 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);
}
} else if (sketch_item) {
sketch_item.toggle();
}
} else {
// return objects upSelect can choose from
let visible = api.objects().filter(o => o.visible);
let sketches = api.selection.sketches();
if (sketches.length) {
// when sketch selected, append sketch items, too
visible.appendAll(sketches[0].object.children);
};
return visible;
}
});
space.mouse.onDrag((delta, offset, up = false) => {
let { mode, modes } = api;
if (delta) {
if (delta.event?.shiftKey && selection.count()) {
selection.drag({ delta, offset });
}
} else if (up) {
if (selection.count()) {
selection.drag({ end: true });
}
} else if (mode.is([ modes.object, modes.tool, modes.sketch ])) {
// return true if there are draggable elements
return api.objects().length > 0 || api.selection.sketches().length;
}
});
}
function load_files(files) {
log(`loading file...`);
let api = meshApi;
let has_image = false;
let has_svg = false;
let has_gbr = false;
let sketch = api.sketch.selected.one;
for (let file of files) {
has_image = has_image || file.type === 'image/png';
has_svg = has_svg || file.name.toLowerCase().endsWith(".svg") > 0;
has_gbr = has_gbr || file.name.toLowerCase().endsWith(".gbr") > 0;
}
if (sketch && has_gbr) {
fileLoad.load([...files], { flat: true }).then(layers => {
for (let layer of layers.flat()) {
let { circs, closed, open, rects } = layer;
open = open.map(poly => {
const diam = poly.tool?.shape?.diameter;
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();
for (let poly of set) {
sketch.add.polygon({ poly, group });
}
}
}
});
} else
if (sketch && has_svg) {
fileLoad.load([...files], { flat: true })
.then(polys => polys.forEach(set => {
let group = meshApi.util.uuid();
set.forEach(poly => sketch.add.polygon({ poly, group }))
}))
.catch(error => dbug.error(error))
.finally(() => meshApi.log.hide());
} 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,
depth: parseFloat(extrude_height.value)
});
api.modal.hide();
} }),
])
]) ]
});
} 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"),
h.input({ id: "img_border", value: 0, size: 4 })
]),
h.div([
h.label("blur pixels"),
h.input({ id: "img_blur", value: 0, size: 4 })
]),
h.div([
h.label("base pixels"),
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 {
load_files_opt(files);
}
}
function load_files_opt(files, opt) {
return fileLoad.load([...files], opt)
.then(data => call.space_load(data))
.catch(error => log(error).pin({}) && dbug.error(error))
// .finally(() => meshApi.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)))
.promote()
.focus();
}
let metaCache = {};
let keyOnce = {};
function key_once(data) {
const { code, fn } = data;
keyOnce[code] = fn;
}
function key_once_cancel(code) {
delete keyOnce[code];
}
function store_meta() {
meshApi.db.admin.put("meta", metaCache);
}
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
function object_meta(data) {
let { id, meta } = data;
update_meta(id, meta);
}
function object_destroy(id) {
delete metaCache[id];
store_meta();
}
// listen for changes like dark mode toggle
function set_darkmode(dark) {
let { prefs, model } = meshApi;
let { sky, platform } = motoSpace;
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 }
});
platform.set({
light: dark ? 0.08 : 0.08,
grid: dark ? {
colorMajor: 0x666666,
colorMinor: 0x333333,
} : {
colorMajor: 0xcccccc,
colorMinor: 0xeeeeee,
},
});
meshApi.updateFog();
platform.setSize();
for (let m of model.list()) {
m.normals({ refresh: true });
}
prefs.save();
}
function set_normals_length(length) {
let { prefs } = meshApi;
prefs.map.normals.length = length || 1;
prefs.save();
}
function set_normals_color(color) {
let { prefs } = meshApi;
let { map } = prefs;
if (map.space.dark) {
map.normals.color_dark = color || 0;
} else {
map.normals.color_lite = color || 0;
}
prefs.save();
}
function set_surface_radians(radians) {
let { prefs } = meshApi;
prefs.map.surface.radians = parseFloat(radians || 0.1);
prefs.save();
}
function set_surface_radius(radius) {
let { prefs } = meshApi;
prefs.map.surface.radius = parseFloat(radius || 0.2);
prefs.save();
}
function set_wireframe_opacity(opacity) {
let { prefs } = meshApi;
prefs.map.wireframe.opacity = parseFloat(opacity || 0.15);
prefs.save();
}
function set_wireframe_fog(fogx) {
let { prefs } = meshApi;
prefs.map.wireframe.fog = parseFloat(fogx || 3);
prefs.save();
}
function set_snap_value(snap) {
let { prefs } = meshApi;
prefs.map.space.snap = parseFloat(snap || 1);
prefs.save();
}
// bind functions to topics
motoBroker.listeners({
key_once,
key_once_cancel,
load_files,
object_meta,
object_destroy,
object_visible,
space_init,
space_load,
set_darkmode,
set_normals_color,
set_normals_length,
set_surface_radians,
set_surface_radius,
set_wireframe_opacity,
set_wireframe_fog,
set_snap_value
});
// remove version cache bust from url
window.history.replaceState({},'','/v2/mesh/');
// setup init() trigger when dom + scripts complete
document.onreadystatechange = function() {
if (document.readyState === 'complete') {
init();
}
}
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
};

35
src2/main/service.js Normal file
View file

@ -0,0 +1,35 @@
const service_worker = navigator.serviceWorker;
import { license } from '../moto/license.js';
async function start_service_worker() {
const version = license.version;
function debug() {
console.log(`[${version}]`, ...arguments);
}
// install service worker
debug('service worker registration');
try {
// const reg = await navigator.serviceWorker.register("/src/moto/service.js?013", { scope: "/" });
const reg = await navigator.serviceWorker.register("/code/service.js?"+version, { scope: "/" });
if (reg.installing) {
debug('service worker installing');
} else if (reg.waiting) {
debug('service worker waiting');
} else if (reg.active) {
debug('service worker active');
} else {
debug({ service_worker: reg });
}
if (service_worker.controller) {
service_worker.controller.postMessage('ctrl message');
}
} catch (err) {
debug('service worker registration failed', err);
}
}
if (service_worker && self.enable_service) start_service_worker();

1624
src2/mesh/api.js Normal file

File diff suppressed because it is too large Load diff

1034
src2/mesh/build.js Normal file

File diff suppressed because it is too large Load diff

217
src2/mesh/edges.js Normal file
View file

@ -0,0 +1,217 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
/**
* facet edge selecetor. allows for creating new facets
* when two connected edges share a single point.
*/
import { THREE, Line2, LineSegmentsGeometry, LineSegments2, LineGeometry, LineMaterial } from '../ext/three.js';
import { space as motoSpace } from '../moto/space.js';
import { newPoint } from '../geo/point.js';
import { api as meshApi } from './api.js';
const { Vector3, Group } = THREE;
let isActive;
let hovered;
let selected = [];
function line2(verts) {
let geo = new LineSegmentsGeometry();
let segs = new LineSegments2(geo, new LineMaterial({
linewidth: 4,
color: 0x0088ff,
alphaToCoverage: false,
}));
geo.setPositions(verts);
return segs;
}
// hovered edge object
let geo, mat, obj = new Line2(
geo = new LineGeometry(),
mat = new LineMaterial({
linewidth: 6,
color: 0x88ff00,
alphaToCoverage: false,
})
);
// true if values differ by less than 10e-3
function cmp(v1, v2) {
return Math.abs(v1 - v2) < 0.001;
}
// points equal if all axis values are within tolerance of each other
function eq(p1, p2) {
return
cmp(p1.x, p2.x) &&
cmp(p1.y, p2.y) &&
cmp(p1.z, p2.z);
}
// split functions
const edges = {
active() {
return isActive ? true : false;
},
start() {
if (isActive) {
return;
}
let { api } = meshApi;
isActive = true;
selected.length = 0;
obj.visible = false;
motoSpace.scene.add(obj);
// enable temp mode
let state = edges.state = { obj };
let models = state.models = api.selection.models();
let meshes = models.map(m => m.mesh);
// find closest facet edge to hover location
motoSpace.mouse.onHover((int, event, ints) => {
if (!event) {
obj.visible = false;
return meshes;
}
if (event.buttons) {
return;
}
obj.visible = true;
let { point, face, object } = int;
let side = edges.closest_edge(point, face, object);
let { p0, p1 } = side;
let verts = side.verts = [ p0.x, -p0.z, p0.y, p1.x, -p1.z, p1.y ];
geo.setPositions(verts);
hovered = { object, face, side };
});
isActive = true;
},
end() {
if (!isActive) {
return;
}
let space = motoSpace;
space.scene.remove(obj);
space.scene.remove(edges.selected);
space.mouse.onHover(undefined);
isActive = edges.state = undefined;
meshApi.api.selection.update();
},
async add() {
if (selected.length === 2 && selected[0].object === selected[1].object) {
let model = selected[0].object.model;
let pos = new Vector3().fromArray(model.meta.pos);
let verts = selected.map(s => [ s.side.p0, s.side.p1 ]).flat();
// translate into mesh space
for (let v of verts) {
v.x = (v.x - pos.x);
v.y = (v.y - pos.z);
v.z = (v.z + pos.y);
}
// sort points so dups show up next to each other
let sort = verts.sort((a,b) => {
if (a.x !== b.x) {
return a.x - b.x;
} else if (a.y !== b.y) {
return a.y - b.y;
} else {
return a.z - b.z;
}
}).filter((point, index, arr) => {
// allow first point then compare others to one before
return index === 0 || !eq(point, arr[index - 1]);
});
if (sort.length === 3) {
await model.duplicate({ append: [
sort[0].x, -sort[0].z, sort[0].y,
sort[1].x, -sort[1].z, sort[1].y,
sort[2].x, -sort[2].z, sort[2].y,
]});
model.remove();
} else if (verts.length === 4) {
await model.duplicate({ append: [
verts[0].x, -verts[0].z, verts[0].y,
verts[1].x, -verts[1].z, verts[1].y,
verts[2].x, -verts[2].z, verts[2].y,
verts[2].x, -verts[2].z, verts[2].y,
verts[1].x, -verts[1].z, verts[1].y,
verts[3].x, -verts[3].z, verts[3].y,
]});
model.remove();
}
// refresh object target cache
edges.end();
edges.start();
}
},
clear() {
selected.length = 0;
motoSpace.scene.remove(edges.selected);
},
select() {
motoSpace.scene.remove(edges.selected);
if (!hovered) return;
const { object, face, side } = hovered;
let add = true;
for (let i=0; i<selected.length; i++) {
let s = selected[i];
let { p0, p1 } = s.side;
let sp = newPoint(
(p0.x + p1.x) / 2,
(p0.y + p1.y) / 2,
(p0.z + p1.z) / 2,
);
let tp = newPoint(
(side.p0.x + side.p1.x) / 2,
(side.p0.y + side.p1.y) / 2,
(side.p0.z + side.p1.z) / 2,
);
// because two faces can share edges that don't match
// we compare midpoints of the line segments for a match
let pd = sp.distTo3D(tp);
if (s.object === object && Math.abs(pd) < 0.0001) {
selected.splice(i,1);
add = false;
break;
}
}
if (add) {
selected.push(hovered);
}
const group = edges.selected = new Group();
motoSpace.scene.add(edges.selected);
let verts = selected.map(s => s.side.verts).flat();
group.add(line2(verts));
},
closest_edge(point, face, object) {
let { position } = object.geometry.attributes;
let matrix = object.matrixWorld;
point = newPoint(point.x, point.y, point.z);
let v0 = new Vector3(position.getX(face.a), position.getY(face.a), position.getZ(face.a)).applyMatrix4(matrix);
let v1 = new Vector3(position.getX(face.b), position.getY(face.b), position.getZ(face.b)).applyMatrix4(matrix);
let v2 = new Vector3(position.getX(face.c), position.getY(face.c), position.getZ(face.c)).applyMatrix4(matrix);
let l0 = { p0:v0, p1:v1, d:point.distToLine3D(v0, v1), v:[face.a,face.b].sort() };
let l1 = { p0:v1, p1:v2, d:point.distToLine3D(v1, v2), v:[face.b,face.c].sort() };
let l2 = { p0:v2, p1:v0, d:point.distToLine3D(v2, v0), v:[face.c,face.a].sort() };
return [ l0, l1, l2 ].sort((a,b) => a.d - b.d)[0];
}
};
export default edges;
export { edges };

92
src2/mesh/geom.js Normal file
View file

@ -0,0 +1,92 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { THREE } from '../ext/three.js';
const { Matrix4, Matrix3, Vector3, Box3 } = THREE;
import { license as motoLicense } from '../moto/license.js';
// geometry helper functions
const geom = {
// given a closed polyline as an expanded point array
// return enclosed area with sign indicating winding order
areaSigned(points, axes = 3) {
let area = 0;
let len = points.length;
for (let p1, p2, pi = 0; pi < len; ) {
p1 = points.slice(pi, pi + axes);
pi += axes;
p2 = pi < len - axes ?
points.slice(pi, pi + axes) :
points.slice(0, axes);
area += (p2[0] - p1[0]) * (p2[1] + p1[1]);
}
return area / 2;
},
// returns an unsigned area generally more useful for comparison
area(points) {
return Math.abs(geom.areaSigned(points));
},
// given an expanded point array, return bounds
bounds(points, axes = 3) {
let box = new Box3();
let p3 = new Vector3();
for (let p, pi = 0, len = points.length; pi < len; pi += axes) {
box.expandByPoint(p3.fromArray(points, pi));
}
return box;
},
// given two nest loop records, determine if
// inner is completely inside outer without overlap
isInside(outer, inner) {
return outer.bounds.containsBox(inner.bounds);
},
// given an array of closed polyline arrays, determine parentage
nest(loops) {
// create record per loop
let recs = loops.map(points => {
return {
points,
area: 0,
depth: 0,
inner: [],
bounds: geom.bounds(points)
}
});
// sort loops by area ascending and cache loop area
recs = recs.sort((a,b) => {
a.area = a.area || geom.areaSigned(a.points);
b.area = b.area || geom.areaSigned(b.points);
return Math.abs(a.area) - Math.abs(b.area);
});
// deep nesting
let count = recs.length;
outer: for (let i = 0; i < count - 1; i++) {
let child = recs[i];
for (let j = i + 1; j < count; j++) {
let parent = recs[j];
if (geom.isInside(parent, child)) {
child.parent = parent;
parent.inner.push(child);
break outer;
}
}
}
// calculate inner depth (steps from topmost)
// let inners = recs.filter(r => r.parent);
for (let rec of recs) {
for (let p = rec.parent, d = 0; p ; p = p.parent) {
rec.depth++;
}
}
return recs;
}
};
export { geom };

170
src2/mesh/group.js Normal file
View file

@ -0,0 +1,170 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { THREE } from '../ext/three.js';
const { Group, Box3 } = THREE;
import { license as motoLicense } from '../moto/license.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 { object as meshObject } from './object.js';
import { api as meshApi } from './api.js';
import { model as meshModel } from './model.js';
const call = motoBroker.send;
const worker = motoClient.fn;
class MeshGroup extends meshObject {
// @param group {MeshModel[]}
constructor(models = [], id, name) {
super(id);
this.group3 = new Group();
this.models = [];
this.name = name;
for (let model of models) {
this.add(model);
}
}
get type() {
return "group";
}
get object() {
return this.group3;
}
get bounds() {
let box3 = new Box3();
for (let model of this.models) {
box3.union(model.world_bounds);
}
return box3;
}
// @param model {MeshModel}
add(model) {
model.group = this;
this.models.addOnce(model);
this.group3.add(model.mesh);
motoSpace.update();
call.model_add({model, group:this});
worker.group_add({id: this.id, model:model.id});
// update data store
meshApi.db.space.put(this.id, this.models.map(m => m.id));
return this;
}
// @param model {MeshModel}
// @param free {boolean} prevent cleanup of model so it can be re-used
remove(model, opt = { free: true }) {
// remove all models and group
if (arguments.length === 0) {
for (let m of this.models.slice()) {
this.remove(m);
}
return;
}
this.models.remove(model);
this.group3.remove(model.mesh);
// create message for listeners
call.model_remove({model, group:this});
// trigger sync with worker
worker.group_remove({id: this.id, model: model.id});
// ensure worker cleanup of model
if (opt.free) model.remove(true);
// auto-remove group when empty
if (this.group3.children.length === 0) {
meshApi.group.remove(this);
// manage lifecycle with worker, mesh app caches, etc
this.destroy();
} else {
// update data store
meshApi.db.space.put(this.id, this.models.map(m => m.id));
}
motoSpace.update();
return this;
}
promote() {
let { dim, mid } = this.bounds;
let { x, y, z } = dim;
let max = Math.max(x, y, z);
if (max < 2) {
meshApi.log.emit(`auto-scaling import from ${max.round(5)}`);
this.scale(1000, 1000, 1000);
}
let { center, floor } = meshApi.prefs.map.space;
if (center !== false) this.centerXY();
if (floor !== false) this.floor();
return this;
}
rotate(x = 0, y = 0, z = 0) {
this.log('group-rotate', ...arguments);
for (let model of this.models) {
model.rotate(...arguments);
}
return this;
}
qrotate(quaternion) {
this.log('group-rotate', quaternion.toArray());
for (let model of this.models) {
model.qrotate(quaternion);
}
return this;
}
scale(x = 1, y = 1, z = 1) {
this.log('group-scale', ...arguments);
for (let m of this.models) {
m.scale(x, y, z);
}
return this;
}
move(x = 0, y = 0, z = 0) {
this.log('group-move', ...arguments);
if (!(x || y || z)) return;
for (let model of this.models) {
model.move(x, y, z);
}
return this;
}
center() {
let b = this.bounds;
return this.move(-b.mid.x, -b.mid.y, -b.mid.z);
}
centerXY() {
let b = this.bounds;
return this.move(-b.mid.x, -b.mid.y, 0);
}
opacity() {
for (let model of this.models) {
model.opacity(...arguments);
}
}
wireframe() {
for (let model of this.models) {
model.wireframe(...arguments);
}
}
normals() {
for (let model of this.models) {
model.normals(...arguments);
}
}
select() {
for (let model of this.models) {
model.select(...arguments);
}
}
}
export { MeshGroup as group };

802
src2/mesh/model.js Normal file
View file

@ -0,0 +1,802 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { THREE } from '../ext/three.js';
import { client as motoClient } from '../moto/client.js';
import { api as meshApi } from './api.js';
import { object as meshObject } from './object.js';
import { model as meshModel } from './model.js';
import { newPolygon, polygons } from '../geo/polygon.js';
import { space } from '../moto/space.js';
import { util } from './util.js';
const { MeshPhongMaterial, MeshBasicMaterial, LineBasicMaterial, BufferGeometry, BufferAttribute, DoubleSide, Mesh, Box3, Vector3, Triangle } = THREE;
const worker = motoClient.fn;
const zero = new Vector3(0,0,0);
/** default materials **/
const materials = {
// model unselected
normal: new MeshPhongMaterial({
flatShading: true,
side: DoubleSide,
transparent: true,
shininess: 125,
specular: 0x202020,
color: 0xf0f000,
opacity: 1
}),
// model selected
select: new MeshPhongMaterial({
flatShading: true,
side: DoubleSide,
transparent: true,
shininess: 125,
specular: 0x202020,
color: 0x00e000,
opacity: 1
}),
// model selected as tool
tool: new MeshPhongMaterial({
flatShading: true,
side: DoubleSide,
transparent: true,
shininess: 125,
specular: 0x202020,
color: 0xe00000,
opacity: 1
}),
// face selected (for groups ranges)
face: new MeshPhongMaterial({
flatShading: true,
side: DoubleSide,
transparent: true,
shininess: 100,
specular: 0x202020,
color: 0x0088ee,
opacity: 1
}),
wireframe: new MeshBasicMaterial({
side: DoubleSide,
wireframe: true,
color: 0x0,
transparent: true,
opacity: 0.5
}),
wireline: new LineBasicMaterial({
side: DoubleSide,
color: 0x0,
transparent: true,
opacity: 0.5
}),
};
/** 3D model rendered on plaform **/
class MeshModel extends meshObject {
constructor(data, id) {
super(id);
let { file, mesh, vertices } = data;
if (!mesh) {
dbug.error(`'${file}' missing mesh data`);
return;
}
// remove file name extensions
let text = file || '';
let dot = text.lastIndexOf('.');
if (dot > 0) file = text.substring(0, dot);
// create local materials
this.mats = {
normal: materials.normal.clone(),
select: materials.select.clone(),
tool: materials.tool.clone(),
face: materials.face.clone()
};
// information about selected faces, lines, and vertices
// vertex compound keys are sorted least index to greatest
this.sel = {
faces: [], // first index into vertices
lines: {}, // key = vertex-vertex, val = [ faces ]
verts: {}, // key = x-y-z, val = { faces:[], sphere }
};
this.file = file || 'unnamed';
this.load(mesh || vertices);
}
get type() {
return "model";
}
get object() {
return this.mesh;
}
get bounds() {
return this.geometry.boundingBox.clone();
}
get world_bounds() {
return this.bounds.translate(this.position());
}
get positions() {
return this.geometry.attributes.position.array;
}
get matrixWorld() {
return this.mesh.matrixWorld;
}
drag(opt = {}) {
let { start, delta, offset, end } = opt;
let { snap, snapon } = meshApi.prefs.map.space;
if (start) {
let mid = this.bounds.mid;
this._save = {
pos: Object.assign({}, mid),
start: Object.assign({}, mid),
}
} else if (end) {
delete this._save;
} else if (offset) {
let { pos, start } = this._save;
let target = {
x: start.x + offset.x,
y: start.y + offset.y,
z: start.z + offset.z
};
if (snapon && snap) {
target.x = Math.round(target.x / snap) * snap;
target.y = Math.round(target.y / snap) * snap;
}
delta = {
x: target.x - pos.x,
y: target.y - pos.y,
z: target.z - pos.z
};
pos.x += delta.x;
pos.y += delta.y;
pos.z += delta.z;
}
if (delta) {
this.move(delta.x, delta.y, delta.z);
}
}
qrotate(quaternion) {
this.log('model-rotate', quaternion.toArray());
this.geometry.applyQuaternion(quaternion);
this.geometry._model_invalid = true;
this.updateBounds();
}
scale(x = 1, y = 1, z = 1) {
if (x === 1 && y === 1 && z === 1) return;
this.log('model-scale', ...arguments, this.bounds);
this.geometry.scale(x, y, z);
this.geometry._model_invalid = true;
this.updateBounds();
}
translate(x = 0, y = 0, z = 0) {
if (!(x || y || z)) return;
this.log('model-translate', ...arguments);
this.geometry.translate(x, y, z);
this.geometry._model_invalid = true;
this.updateBounds();
}
move(x = 0, y = 0, z = 0) {
if (!(x || y || z)) return;
this.log('model-move', ...arguments);
let pos = this.position();
return this.position(pos.x + x, pos.y + y, pos.z + z);
}
position() {
let pos = this.object.position;
if (arguments.length === 0) {
return pos;
}
pos.set(...arguments);
this.metaChanged({ pos: pos.toArray() });
return this;
}
// preserves world location while updating mesh for rotation and scaling
// moves mesh center to 0,0,0 via translation then
// moves mesh object to former bounds center
reCenter() {
this.log('model-recenter');
let pos = this.position();
let { mid } = this.bounds;
this.translate(-mid.x, -mid.y, -mid.z);
this.position(mid.x + pos.x, mid.y + pos.y, mid.z + pos.z);
return this;
}
// preserves world location while updating mesh for rotation and scaling
// moves mesh center to current target offset via translation then
// moves mesh object center to target
centerTo(to) {
this.log('model-centerto', to);
let pos = this.position();
let { mid } = this.bounds;
this.translate(to.x - mid.x, to.y - mid.y, to.z - mid.z);
this.position(pos.x, pos.y, pos.z);
return this;
}
// when geometry updates, recompute bounds for ray intersections
// and sync data to worker and indexed db
updateBounds() {
if (this._wire)
this._wire.geometry.attributes.position.needsUpdate = true;
this.attributes.position.needsUpdate = true;
this.geometry.computeBoundingBox();
this.geometry.computeBoundingSphere();
this.log('update-bounds', this.geometry.boundingBox);
this.normals({ refresh: true });
this.updateBoundsBox();
space.update();
this.sync();
}
updateBoundsBox() {
this.group?.updateBoundsBox();
}
mirror() {
return this.duplicate({ mirror: true });
}
// return a model containing just this model
// translated into world coordinates (rebuilt from rotation matrix)
// defaults to returning a new model in a new group
// options to mirror, re-use a group, or update model in-place
duplicate(opt = { select: true }) {
let pos = this.position();
let data = this.attributes.position.clone().array;
if (opt.append) data = [...data, ...opt.append].toFloat32();
if (opt.x) return this.reload(data);
let model = new meshModel({ file: `${this.file}`, mesh: data });
let group = opt.group || meshApi.group.new();
let bounds = this.group.bounds;
group.add(model);
model.position(pos.x, pos.y, pos.z);
model.wireframe(this.wireframe());
if (opt.select) {
meshApi.selection.add(model);
}
if (opt.mirror) {
group.move(0, 0, bounds.dim.z);
} else if (opt.shift) {
group.move(bounds.dim.x, 0, 0);
}
return model;
}
load(vertices) {
this.log('load');
let geo = new BufferGeometry();
geo.setAttribute('position', new BufferAttribute(vertices, 3));
let meh = this.mesh = new Mesh(geo, [
this.mats.normal,
this.mats.face
]);
geo.addGroup(0, Infinity, 0);
meh.receiveShadow = true;
meh.castShadow = true;
meh.renderOrder = 1;
// sets fallback opacity for wireframe toggle
this.opacity(1);
// this ref allows clicks to be traced to models and groups
meh.model = this;
// sync worker, allows raycasting to work
this.updateBounds();
}
reload(vertices) {
this.log('reload');
let was = this.wireframe(false);
let geo = this.mesh.geometry;
geo.setAttribute('position', new BufferAttribute(vertices, 3));
// signal util.box3expand that geometry changed
geo._model_invalid = true;
geo.computeVertexNormals();
// restore wireframe state
this.wireframe(was);
// fixup normals
this.normals({refresh: true});
// sync worker, allows raycasting to work
this.updateBounds();
// re-gen face index in surface mode
meshApi.mode.check();
}
rename(file) {
this.file = file;
this.sync();
}
// sync to worker and indexeddb for page restoration or worker ops
sync() {
// sync to worker
worker.model_load({ id: this.id, name: this.file, vertices: this.positions });
// persist in db so it can be restored on page load
meshApi.db.space.put(this.id, {
file: this.file,
mesh: this.attributes.position.array
});
}
get group() {
return this._group;
}
set group(gv) {
if (gv && this._group && this._group !== gv) {
throw "models can only belong to one group";
}
this._group = gv;
}
get geometry() {
return this.mesh.geometry;
}
get attributes() {
return this.geometry.attributes;
}
get vertices() {
return this.attributes.position.count;
}
get faces() {
return this.vertices / 3;
}
// get, set, or toggle selection of model (coloring)
select(bool, stool) {
const cmat = this.mesh.material[0];
const { normal, select, tool } = this.mats;
if (bool === undefined) {
return cmat !== normal;
}
if (bool.toggle) {
return this.select(!this.select(), cmat === tool);
}
this.mesh.material[0] = bool ? (stool ? tool : select) : normal;
return bool;
}
// return selected state
selected() {
return this.select();
}
// toggle whether this is a boolean subtraction tool or not
tool(bool) {
if (bool === undefined) {
return this.mesh.material[0] === this.mats.tool;
}
return this.select(this.selected(), bool);
}
opacity(ov, opt = {}) {
let mat = this.mesh.material;
if (ov === undefined) {
return mat[0].opacity;
}
if (ov.restore) {
ov = this._op;
} else if (ov.temp !== undefined) {
ov = ov.temp;
} else {
this._op = ov;
}
for (let m of Object.values(this.mats)) {
if (ov <= 0.0) {
m.transparent = false;
m.opacity = 1;
m.visible = false;
} else {
m.transparent = true;
m.opacity = ov;
m.visible = true;
}
}
space.update();
}
highlight() {
if (!this.wireframe().enabled) {
this.opacity({temp: 0.5});
}
}
unhighlight() {
if (!this.wireframe().enabled) {
this.opacity({restore: true});
}
}
wireframe(bool, opt = {}) {
let was = this._wire ? true : false;
if (bool === undefined) {
return was ? {
enabled: true,
opacity: this.opacity(),
color: was ? this._wire.material.color : undefined,
} : {
enabled: false
};
}
if (bool.toggle) {
bool = !was;
}
// no change
if (was === bool) {
return was;
}
if (was) {
this.mesh.remove(this._wire);
this._wire = undefined;
this.opacity({restore: true});
}
if (bool === 'edges') {
let edges = new THREE.EdgesGeometry(this.mesh.geometry, 5);
this._wire = new THREE.LineSegments(edges, materials.wireframe);
this.mesh.add(this._wire);
this.opacity({temp: opt.opacity || 0.15});
} else if (bool) {
this._wire = new Mesh(this.mesh.geometry.shallowClone(), materials.wireframe);
this.mesh.add(this._wire);
this.opacity({temp: opt.opacity || 0.15});
}
space.update();
return was;
}
normals(bool) {
let was = this._norm ? true : false;
if (bool === undefined) {
return was;
}
if (bool.toggle) {
bool = !was;
}
if (bool.refresh && !was) {
return;
}
// no change
if (was === bool) {
return was;
}
if (was) {
this.mesh.remove(this._norm);
this._norm = undefined;
}
if (bool) {
this.mesh.add(this._norm = util.faceNormals(this.mesh));
}
}
render() {
// TODO
}
// invert normals for entire mesh or selected faces depending on mode
invert(mode) {
let { modes } = meshApi;
let geo = this.mesh.geometry;
let pos = geo.attributes.position;
let arr = pos.array;
function swap(i) {
let v1x = arr[i ];
let v1y = arr[i+1];
let v1z = arr[i+2];
arr[i ] = arr[i+3];
arr[i+1] = arr[i+4];
arr[i+2] = arr[i+5];
arr[i+3] = v1x;
arr[i+4] = v1y;
arr[i+5] = v1z;
}
switch (mode) {
case modes.object:
for (let i=0, l=arr.length; i<l; i += 9) {
swap(i);
}
break;
case modes.surface:
case modes.face:
for (let face of this.sel.faces) {
swap(face * 9);
}
break;
}
this.reload(arr);
if (this._norm) this._norm.update();
}
// split model along given plane
split(plane) {
// extract axes from plane and split when present (only z for now)
let { z } = plane;
let { id, group } = this;
return new Promise((resolve,reject) => {
worker.model_split({ id, z }).then(data => {
let { o1, o2 } = data;
if (!(o1 || o2)) {
return resolve(group);
}
if (o1?.length)
group.add(new meshModel({
file: `${this.file}-bot`,
mesh: o1
}).reCenter());
if (o2?.length)
group.add(new meshModel({
file: `${this.file}-top`,
mesh: o2
}).reCenter());
this.remove();
resolve(group);
});
});
}
// release from a group but remain in memory and storage
// so it can be re-assigned to another group
ungroup() {
if (this.group) {
this.group.remove(this, { free: false });
this.group = undefined;
}
return this;
}
// remove model from group and space
remove() {
if (arguments.length === 0) {
// direct call requires pass through group
this.group.remove(this);
this.group = undefined;
this.removed = 'pending';
} else {
// manage lifecycle with worker, mesh app caches, etc
this.destroy();
// tag removed for debugging
this.removed = 'complete';
}
}
collectFacesByMaterialIndex(index) {
let { geometry } = this.mesh;
let { groups } = geometry;
let { array } = geometry.attributes.position;
let newtot = 0;
// filter to unselected groups
groups = groups.filter(g => g.materialIndex === index);
for (let group of groups) {
let start = group.start * 3;
let count = group.count * 3;
newtot += group.count < Infinity ? count : array.length - start;
}
// nothing to do if new length is the same
if (newtot === array.length) {
return;
}
let newverts = new Float32Array(newtot);
let pos = 0;
// copy back unselected faces
for (let group of groups) {
let start = group.start * 3;
let count = group.count * 3;
if (count === Infinity) count = array.length - start;
let slice = array.slice(start, start + count);
newverts.set(slice, pos);
pos += count;
}
return newverts;
}
clearSelections() {
// clear face selections (since they've been deleted);
this.sel.faces = [];
this.updateSelections();
}
selectionToSketch() {
let { polys, quaternion } = this.selectionToRotationPolys();
if (!polys) {
return;
}
quaternion.invert();
// center points, move to Z=0
let bounds = new Box3().setFromArray(polys.map(p => p.points.map(p => p.toArray())).flat().flat());
let center = bounds.getCenter(new Vector3);
polys.forEach(p => p.move({ x: -center.x, y: -center.y, z: -center.z }));
let normal = new Vector3(0,0,1).applyQuaternion(quaternion);
let sketch = meshApi.add.sketch({
normal,
center: center.applyQuaternion(quaternion).add(this.position())
});
for (let poly of polys) {
sketch.add.polygon({ poly });
}
return sketch;
}
selectionToRotationPolys() {
let selverts = this.collectFacesByMaterialIndex(1);
if (selverts?.length) {
let points = [...selverts].group(3).map(a => new Vector3().fromArray(a));
let tris = points.group(3).map(a => new Triangle(...a));
let norms = tris.map(t => t.getNormal(new Vector3()));
let norm = norms.reduce((a,b) => a.add(b)).normalize();
// compute quaternion and rotate triangles to face Z up
let targetNorm = new THREE.Vector3(0, 0, 1);
let rotato = new THREE.Quaternion().setFromUnitVectors(norm, targetNorm);
points.forEach(p => p.applyQuaternion(rotato));
// union / nest result
let polys = tris.map(t => newPolygon().fromVectors([ t.a, t.b, t.c ]));
let union = polygons.union(polys,0,true);
return { polys: union, quaternion: rotato };
} else {
return {};
}
}
selectionFlatten() {
let faces = this.sel.faces;
if (faces.length) {
worker.model_flatten({ id: this.id, faces }).then(data => {
if (data.vertices) {
this.reload(data.vertices);
this.sel.faces = [];
this.updateSelections();
}
});
}
}
triangulateSelections() {
let { polys, quaternion } = this.selectionToRotationPolys();
if (polys) {
let ears = polys.map(p => p.earcut()).flat();
let nupoints = ears.map(p => p.points).flat().map(p => p.toVector3());
// invert quaternion to restore points alignment to original face
quaternion.invert();
nupoints.forEach(p => p.applyQuaternion(quaternion));
let nuverts = nupoints.map(p => p.toArray()).flat().toFloat32();
// remove selection and append nuverts
this.deleteSelections(nuverts);
}
}
deleteSelections(append) {
if (!this.sel.faces.length) {
return;
}
let newverts = this.collectFacesByMaterialIndex(0);
if (newverts) {
if (append) {
let all = new Float32Array(newverts.length + append.length);
all.set(newverts);
all.set(append, newverts.length);
newverts = all;
}
this.reload(newverts);
// clear face selections (since they've been deleted);
this.sel.faces = [];
this.updateSelections();
}
}
updateSelections() {
let groups = util.facesToGroups(this.sel.faces);
let geo = this.mesh.geometry;
geo.clearGroups();
for (let group of groups) {
geo.addGroup(group.start*3, group.count*3, group.mat || 0);
}
}
selectFaces(list = [], action = {}) {
let faces = this.sel.faces;
let map = {};
for (let f of list) {
map[f] = f;
}
if (action.toggle) {
faces = faces.filter(f => {
if (map[f] !== undefined) {
map[f] = undefined;
return false;
} else {
return true;
}
});
faces.appendAll(Object.entries(map).filter(kv => {
return kv[1] !== undefined;
}).map(kv => {
return kv[1];
}));
this.sel.faces = faces;
} else if (action.clear) {
this.sel.faces = faces.filter(f => map[f] === undefined);
} else if (action.select) {
for (let f of faces) {
map[f] = undefined;
}
faces.appendAll(Object.values(map).filter(f => f !== undefined));
}
}
toggleSelectedVertices(vert) {
if (Array.isArray(vert)) {
for (let e of vert) {
this.toggleSelectedVertices(e);
}
return;
}
let sel = this.sel;
let key = [x,y,z].map(v => v.round(5)).join('-');
let rec = sel.verts[key];
if (rec) {
delete sel.verts[key];
space.scene.remove(rec.sphere);
} else {
let geometry = new THREE.SphereGeometry( 0.5, 16, 16 );
let material = new MeshPhongMaterial( { color: 0x777777, transparent: true, opacity: 0.25 } );
let sphere = new Mesh( geometry, material );
sphere.position.set(x, y, z);
space.scene.add(sphere);
sel.verts[key] = {
sphere,
faces,
verts
};
}
}
// find adjacent faces to clicked point/line on a face
find(int, action, surface) {
let { point, face } = int;
let { x, y, z } = point;
let { a, b, c } = face;
let timer = setTimeout(() => {
timer = undefined;
meshApi.log.emit("matching surface").pin();
}, 150);
// let mark = Date.now();
worker.model_select({
id: this.id, x, y:-z, z:y, a, b, c, surface
}).then(data => {
// meshApi.log.emit(`... data time = ${Date.now() - mark}`); mark = Date.now();
if (timer) {
clearTimeout(timer);
}
let { faces, edges, verts, point } = data;
// console.log({data});
// this.toggleSelectedVertices(verts);
this.selectFaces(faces, action);
// meshApi.log.emit(`... select time = ${Date.now() - mark}`); mark = Date.now();
this.updateSelections();
if (!timer) {
api.log.emit("surface match complete").unpin();
space.refresh();
}
// meshApi.log.emit(`... paint time = ${Date.now() - mark}`);
});
}
}
export { MeshModel as model, materials };

204
src2/mesh/object.js Normal file
View file

@ -0,0 +1,204 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { THREE } from '../ext/three.js';
const { Matrix4, Vector3, Box3, Box3Helper, Quaternion } = THREE;
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 worker = motoClient.fn;
const lookUp = new Vector3(0,0,-1);
// broker updates generated by objects
const publish = {
meta: motoBroker.bind("object_meta"),
destroy: motoBroker.bind("object_destroy"),
visible: motoBroker.bind("object_visible"),
};
class MeshObject {
constructor(id) {
this.id = id || meshUtil.uuid();
this.meta = { pos: [0,0,0] };
worker.object_create({ id: this.id, type: this.type });
worker.object_meta({ id: this.id, meta: this.meta });
this.log('NEW');
}
log() {
// meshApi.log.emit(this.id, this.type, ...arguments);
}
get type() {
throw "type() requires implementation";
}
// @returns {THREE.Object3D}
get object() {
throw "object() requires implementation";
}
get bounds() {
throw "bounds() requires implementation";
}
// manage lifecycle with worker, mesh app caches, etc
destroy() {
// update worker state
worker.object_destroy({id: this.id});
// update object store
meshApi.db.space.remove(this.id);
// main app cache workspace updates
publish.destroy(this.id);
}
// used during state restoration
applyMeta(meta = {}) {
this.log('apply-meta', meta);
if (meta.pos) this.position(...meta.pos);
this.visible(meta.visible ?? true);
this.metaChanged(meta);
return this;
}
// used during state restoration
applyMatrix(elements) {
if (elements) {
this.log('apply-matrix');
this.object.applyMatrix4(new Matrix4().fromArray(elements));
}
return this;
}
// used during model splitting
applyMatrix4(matrix) {
return this.applyMatrix(matrix.elements);
}
select() {
throw "select() requires implementation";
}
remove() {
throw "remove() requires implementation";
}
focus() {
meshApi.focus(this.object);
return this;
}
visible(opt) {
if (opt === undefined) {
return this.object.visible;
}
if (opt.toggle) {
this.visible(!this.visible());
} else {
this.object.visible = opt;
}
publish.visible({ id: this.id, visible: this.object.visible });
return this;
}
floor() {
let b = this.bounds;
return this.move(0, 0, -b.min.z);
}
center() {
throw "center() requires implementation";
let b = this.bounds;
return this.move(-b.mid.x, -b.mid.y, -b.mid.z);
}
centerXY() {
throw "centerXY() requires implementation";
let b = this.bounds;
return this.move(-b.mid.x, -b.mid.y, 0);
}
move() {
throw "move() requires implementation";
}
scale() {
throw "scale() requires implementation";
}
qrotate(quaternion) {
throw "qrotate() requires implementation";
}
rotate(x = 0, y = 0, z = 0) {
this.log('object-rotate', ...arguments);
let m = new Matrix4();
if (x) m.multiply(new Matrix4().makeRotationX(x));
if (y) m.multiply(new Matrix4().makeRotationY(y));
if (z) m.multiply(new Matrix4().makeRotationZ(z));
this.qrotate(new Quaternion().setFromRotationMatrix(m));
return this;
}
rotation() {
throw "rotation() requires implementation";
}
// rotate object in the directon of normal (place face on Z plane)
rotateTowardZ(normal) {
let q = new Quaternion().setFromUnitVectors(normal, lookUp);
this.qrotate(q);
this.floor();
}
position() {
throw "position() requires implementation";
}
showBounds(bool) {
let was = this._showBounds;
if (bool && bool.toggle) {
bool = !this._showBounds;
}
if (was === bool) {
return;
}
this._showBounds = bool;
this.updateBoundsBox();
}
updateBoundsBox() {
this.log('update-bounds-box', this._showBounds);
let helper = this._boundsBox;
let world = motoSpace.world;
if (helper) {
world.remove(helper);
}
if (this._showBounds) {
let { mid, dim } = this.bounds;
let b3 = new Box3().setFromCenterAndSize(
new Vector3(mid.x, mid.y, mid.z),
new Vector3(dim.x, dim.y, dim.z)
);
let helper = this._boundsBox = new Box3Helper(b3, 0x555555);
world.add(helper);
return true;
}
}
metaChanged(values = {}) {
this.object.updateMatrix();
this.updateBoundsBox();
motoSpace.update();
Object.assign(this.meta, values, {
// matrix: this.object.matrix.elements
});
worker.object_meta({ id: this.id, meta: this.meta });
publish.meta({ id: this.id, meta: this.meta });
return this;
}
}
export { MeshObject as object };

7
src2/mesh/pool.js Normal file
View file

@ -0,0 +1,7 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { license as motoLicense } from '../moto/license.js';
import { worker as motoWorker } from '../moto/worker.js';
// todo
export const pool = {};

641
src2/mesh/sketch.js Normal file
View file

@ -0,0 +1,641 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { THREE } from '../ext/three.js';
const { BufferGeometry, BufferAttribute, Quaternion, MeshBasicMaterial, LineBasicMaterial, LineSegments, DoubleSide, PlaneGeometry, EdgesGeometry, SphereGeometry, Vector3, Mesh, Group } = THREE;
import { license as motoLicense } from '../moto/license.js';
import { client as motoClient } from '../moto/client.js';
import { space as motoSpace } from '../moto/space.js';
import { broker as motoBroker } from '../moto/broker.js';
import { object as meshObject } from './object.js';
import { api as meshApi } from './api.js';
import { util as meshUtil } from './util.js';
import { group as meshGroup } from './group.js';
import { newPolygon } from '../geo/polygon.js';
import { polygons as POLYS } from '../geo/polygons.js';
const worker = motoClient.fn;
const drag = {};
const hpos = [
[-1, 1, 1],
[ 1, 1, 1],
[-1,-1, 1],
[ 1,-1, 1],
];
const material = {
normal: new MeshBasicMaterial({ color: 0x888888, side: DoubleSide, transparent: true, opacity: 0.25 }),
selected: new MeshBasicMaterial({ color: 0x889988, side: DoubleSide, transparent: true, opacity: 0.25 }),
highlight: new LineBasicMaterial({ color: 0x88ff88, side: DoubleSide, transparent: true, opacity: 0.50 }),
};
function log() {
meshApi.log.emit(...arguments);
}
/** 2D plane containing open and closed polygons which can be extruded **/
class MeshSketch extends meshObject {
constructor(opt = {}) {
super(opt.id);
this.file = opt.file || this.id;
this.scale = opt.scale || { x: 50, y: 50, z: 0 };
this.center = opt.center || { x: 0, y: 0, z: 0 };
this.normal = opt.normal || { x: 0, y: 0, z: 1 };
this.items = opt.items || [];
const group = this.group = new Group();
group.sketch = this;
const planeGeometry = new PlaneGeometry(1, 1);
const planeMaterial = material.normal;
const plane = this.plane = new Mesh(planeGeometry, planeMaterial);
plane.sketch = this;
const outlineGeometry = new EdgesGeometry(planeGeometry);
const outlineMaterial = material.normal;
const outline = this.outline = new LineSegments(outlineGeometry, outlineMaterial);
const handleGeometry = new SphereGeometry(0.5, 16, 16);
const handleMaterial = material.normal;
const handles = this.handles = [];
const corners = this.corners = [ [-0.5, 0.5, 0], [0.5, 0.5, 0], [-0.5, -0.5, 0], [0.5, -0.5, 0] ];
for (let corner of corners) {
const handle = new Mesh(handleGeometry, handleMaterial);
handle.position.set(...corner);
handle.sketch = this;
handles.push(handle);
}
group.add(...handles, outline, plane);
this.render_defer();
}
update() {
let { group, plane, outline, center, handles, corners, normal, scale } = this;
plane.scale.set(scale.x, scale.y, 1);
outline.scale.set(scale.x, scale.y, 1);
group.position.set(center.x, center.y, center.z);
for (let i=0; i<4; i++) {
const handle = handles[i];
const corner = corners[i];
handle.position.set(
(corner[0] * scale.x),
(corner[1] * scale.y),
(corner[2] * scale.z)
);
}
normal = new Vector3(normal.x, normal.y, normal.z).normalize();
group.quaternion.copy(new Quaternion().setFromUnitVectors(new Vector3(0, 0, 1), normal));
this.#db_save();
}
lookat(x,y,z) {
this.group.lookAt(new Vector3(x,y,z));
motoSpace.update();
}
#db_save() {
let { center, normal, scale, type, file, items } = this;
meshApi.db.space.put(this.id, { center, normal, scale, type, file, items });
}
#db_remove() {
meshApi.sketch.remove(this);
meshApi.db.space.remove(this.id);
}
get type() {
return "sketch";
}
get object() {
return this.group;
}
get meshes() {
return this.group.children.filter(c => {
return c.sketch || (c.sketch_item && c.sketch_item.selected) ? c : undefined
}).reverse();
}
get elements() {
return this.group.children.filter(c => c.sketch_item);
}
get selection() {
let sketch = this;
return {
// for boolean and patterning, we need the polygon artifacts
mesh_items() {
return sketch.group.children.filter(c => c.sketch_item && c.sketch_item.selected);
},
items() {
return sketch.selection.mesh_items().map(c => c.sketch_item);
},
count() {
return sketch.items.filter(i => i.selected).length;
},
all() {
sketch.items.forEach(i => i.selected = true);
sketch.render();
motoBroker.publish('sketch_selections');
return sketch.items.length;
},
// return true if any selections were cleared
clear() {
let sel = sketch.items.filter(i => i.selected);
if (sel.length) {
sel.forEach(s => s.selected = false);
sketch.render();
motoBroker.publish('sketch_selections');
}
return sel.length;
},
// return true if any selections were deleted
delete() {
let sel = sketch.items.filter(i => i.selected);
if (sel.length) {
sketch.items = sketch.items.filter(i => !i.selected);
sketch.render();
motoBroker.publish('sketch_selections');
}
return sel.length;
},
centerXY() {
let sel = sketch.items.filter(i => i.selected);
for (let s of sel) {
s.center.x = 0;
s.center.y = 0;
}
return sel.length;
}
}
}
get add() {
let sketch = this;
return {
circle(opt = {}) {
return sketch.add.item({
type: "circle",
selected: true,
...Object.assign({}, { center: {x:0, y:0, z:0}, radius:5 }, opt)
});
},
rectangle(opt = {}) {
return sketch.add.polygon({
...opt,
poly: newPolygon().centerRectangle(
opt.center || {x:0, y:0, z:0},
opt.height || 15,
opt.width || 10
)
})
},
polygon(opt = {}) {
let poly = opt.poly;
delete opt.poly;
let { width, miter } = poly._svg || {};
return sketch.add.item({
type: "polygon",
width,
miter,
selected: true,
...Object.assign({}, { center: { x:0, y:0, z:0 } }, opt),
...poly.toObject()
});
},
item(item) {
item.group = item.group || meshUtil.uuid();
sketch.items.push(item);
sketch.render_defer();
meshApi.selection.update_defer();
return item;
}
}
}
get boolean() {
let sketch = this;
return {
union() {
let items = sketch.selection.mesh_items();
if (items.length < 2) return log('operation requires at least 2 items');
let polys = items.map(i => i.sketch_item.poly);
let union = POLYS.union(polys, 0, true);
sketch.selection.delete();
sketch.arrange.group(union.map(poly => sketch.add.polygon({ poly })));
},
intersect() {
let items = sketch.selection.mesh_items();
if (items.length !== 2) return log('operation requires 2 items');
let polys = items.map(i => i.sketch_item.poly);
let trim = POLYS.trimTo([polys[0]], [polys[1]]);
sketch.selection.delete();
for (let poly of trim) {
sketch.add.polygon({ poly });
}
},
difference() {
let items = sketch.selection.mesh_items();
if (items.length !== 2) return log('operation requires 2 items');
let polys = items.map(i => i.sketch_item.poly);
let diff1 = POLYS.diff([polys[0]], [polys[1]]);
let diff2 = POLYS.diff([polys[1]], [polys[0]]);
sketch.selection.delete();
for (let poly of [...diff1, ...diff2]) {
sketch.add.polygon({ poly });
}
},
nest() {
let items = sketch.selection.items();
if (items.length < 2) return log('operation requires at least 2 items');
let polys = items.map(si => si.poly.annotate({group:si.group}).clone(true, ['group']));
let union = POLYS.nest(POLYS.flatten(polys, [], true));
sketch.selection.delete();
for (let poly of union) {
sketch.add.polygon({ poly, group:poly.group });
}
},
evenodd() {
let items = sketch.selection.items();
let polys = items.map(si => si.poly.annotate({group:si.group}));
let even = POLYS.nest(POLYS.flatten(polys.clone(true,['group']), [], true));
let odd = POLYS.nest(POLYS.flatten(even.clone(true,['group']), [], true).filter(p => p.depth > 0));
sketch.selection.delete();
for (let poly of [...even, ...odd]) {
sketch.add.polygon({ poly, group:poly.group });
}
},
flatten() {
let items = sketch.selection.items();
let polys = items.map(si => si.poly.annotate({group:si.group}).clone(true, ['group']));
let flat = POLYS.flatten(polys, [], true);
sketch.selection.delete();
for (let poly of flat) {
sketch.add.polygon({ poly });
}
}
}
}
get arrange() {
let sketch = this;
let { items } = sketch;
return {
move(item, target) {
let pos = items.indexOf(item);
if (pos < 0 || pos >= items.length) {
throw new Error('index out of bounds');
}
if (target === 'top') {
let x = items.splice(pos,1);
return items.appendAll(x);
} else if (target === 'bottom') {
let x = items.splice(pos,1);
return sketch.items = [...x, ...items];
}
let newPos = { 'down': pos - 1, 'up': pos + 1 }[target];
if (newPos >= 0 && newPos < items.length) {
[items[pos], items[newPos]] = [items[newPos], items[pos]];
}
},
up(item) {
sketch.arrange.move(item, 'up');
},
down(item) {
sketch.arrange.move(item, 'down');
},
top(item) {
sketch.arrange.move(item, 'top');
},
bottom(item) {
sketch.arrange.move(item, 'bottom');
},
group(items) {
let group = Date.now().toString(36);
items.forEach(i => i.group = group);
sketch.render();
},
ungroup(items) {
items.forEach(i => i.group = meshUtil.uuid());
sketch.render();
}
}
}
get pattern() {
return {
circle() {
console.log('sketch pattern circle');
},
grid() {
console.log('sketch pattern grid');
}
}
}
rename(newname) {
this.file = newname;
this.#db_save();
}
remove() {
this.#db_remove();
}
highlight() {
this.outline.material = material.highlight;
}
unhighlight() {
this.outline.material = material.normal;
}
select(bool) {
const { plane, handles } = this;
if (bool === undefined) {
return plane.material === material.selected;
}
if (bool.toggle) {
return this.select(!this.select());
}
plane.material = (bool ? material.selected : material.normal);
for (let handle of handles) {
handle.material = bool ? material.highlight : plane.material;
}
this.render();
return bool;
}
move(x, y, z = 0) {
const { target } = drag;
const { center, scale, plane, handles } = this;
const handle = handles.indexOf(target);
if (target === plane) {
center.x += x;
center.y += y;
center.z += z;
this.render();
} else if (handle >= 0) {
const sf = hpos[handle];
center.x += x / 2;
center.y += y / 2;
center.z += z / 2;
scale.x += x * sf[0];
scale.y += y * sf[1];
scale.z += z * sf[2];
this.render();
} else if (Array.isArray(target)) {
for (let item of target) {
let { center } = item;
center.x += x;
center.y += y;
center.z += z;
}
this.render();
} else {
this.center = {x, y, z};
this.render();
}
}
centerXY() {
if (!this.selection.centerXY()) {
this.center.x = 0;
this.center.y = 0;
}
this.render();
}
handle_pos(handle) {
if (handle < 0) return;
let { scale, center } = this;
let v = hpos[handle];
return {
x: v[0] * scale.x/2 + center.x,
y: v[1] * scale.y/2 + center.y,
z: v[2] * scale.z/2 + center.z,
};
}
drag(opt = {}) {
let { items, center, handles } = this;
let { start, delta, offset, end } = opt;
if (start) {
let selected = items.filter(i => i.selected);
let target = start.sketch_item ? selected : start;
let item = Array.isArray(target) ? target[0] : undefined;
drag.handle = handles.indexOf(target);
drag.item = item;
drag.target = target;
drag.start = Object.assign({}, drag.handle >= 0 ?
this.handle_pos(drag.handle) :
item?.center ?? center);
} else if (offset) {
let { start, item, handle } = drag;
let { snap, snapon } = meshApi.prefs.map.space;
let pos = handle >= 0 ?
this.handle_pos(handle) :
item?.center ?? center;
let end = {
x: start.x + offset.x,
y: start.y + offset.y,
z: start.z + offset.z
};
if (snap && snapon) {
end.x = Math.round(end.x / snap) * snap;
end.y = Math.round(end.y / snap) * snap;
}
delta = {
x: end.x - pos.x,
y: end.y - pos.y,
z: end.z - pos.z
};
this.move(delta.x, delta.y, delta.z);
} else if (delta) {
this.move(delta.x, delta.y, delta.z);
} else if (end) {
drag.target = undefined;
} else {
console.log({ invalid_sketch_drag: opt });
}
}
// render items unto the group object
render() {
let { group } = this;
let group_order = {};
let group_next = 0;
let scale = this.scale.z;
// remove previous item/poly-based children of group
group.children.filter(c => c.sketch_item || c.sketch_line).forEach(c => group.remove(c));
// map items into polys into meshes to add to group
for (let item of this.items) {
let order = scale ? (group_order[item.group] ?? group_next++) : group_next++;
group_order[item.group] = order;
let sketch_item = new SketchItem(this, item, order);
group.add(sketch_item.mesh);
group.add(...sketch_item.outs);
}
this.update();
motoSpace.refresh();
}
render_defer() {
meshUtil.defer(() => this.render());
}
extrude(opt = {}) {
let { selection, height, chamfer, chamfer_top, chamfer_bottom } = opt;
let models = [];
let items = this.group.children
.filter(c => c.sketch_item)
.filter(c => !selection || c.sketch_item.selected);
for (let item of items) {
let vert = item.sketch_item.extrude(height, {
chamfer,
chamfer_top,
chamfer_bottom
});
let nmdl = new mesh.model({ file: "item", mesh: vert.toFloat32() });
models.push(nmdl);
}
if (models.length) {
log('extrude', this.file || this.id, 'into', models.length, 'solid(s)');
let { center } = this;
let ngrp = meshApi.group.new(models);
ngrp.move(center.x, center.y, center.z);
// align extrusion with sketch plane
const euler = this.group.rotation;
const quaternion = new THREE.Quaternion();
quaternion.setFromEuler(euler);
ngrp.qrotate(quaternion);
}
}
}
class SketchItem {
constructor(sketch, item, order) {
this.sketch = sketch;
this.item = item;
this.order = order;
this.update();
}
get type() {
return "sketch_item";
}
get group() {
return this.item.group;
}
get selected() {
return this.item.selected;
}
toggle() {
let { sketch, item } = this;
item.selected = !item.selected;
if (item.group) {
sketch.items
.filter(i => i.group === item.group)
.forEach(i => i.selected = item.selected);
}
this.update();
sketch.render();
motoBroker.publish('sketch_selections');
}
extrude(z, opt = {}) {
return this.poly.extrude(z, opt);
}
centerXY() {
let { center } = this.item;
center.x = 0;
center.y = 0;
}
update() {
let { item, sketch, order } = this;
let { material } = mesh;
let { type, center, width, height, miter, radius, points, spacing, poly, selected } = item;
let { open_close, open_width, open_type } = meshApi.prefs.map.sketch;
let base = 0.025;
let bump = sketch.scale.z || 0.0001;
if (type === 'circle') {
let circumference = 2 * Math.PI * radius;
points = points || Math.floor(circumference / (spacing || 1));
poly = newPolygon().centerCircle(center, radius, points).annotate({ item } ).rotate(item.rotation || 0);
} else if (type === 'rectangle') {
poly = newPolygon().centerRectangle(center, width, height).annotate({ item });
} else if (type === 'polygon') {
poly = newPolygon().fromObject(item);
poly.move(center);
if (poly.isOpen() && open_close !== true) {
poly = poly.offset_open(width || open_width || 1, open_type, miter)[0];
}
} else {
throw `invalid sketch type: ${type}`;
}
this.poly = poly;
let isSelected = selected && sketch.select();
// create solid filled area
let mat = (isSelected ? material.select : material.normal).clone();
mat.transparent = true;
mat.opacity = 0.5;
let vrt = poly.extrude(0).toFloat32();
let geo = new BufferGeometry();
geo.setAttribute('position', new BufferAttribute(vrt, 3));
let meh = this.mesh = new Mesh(geo, mat);
meh.renderOrder = -1;
meh.sketch_item = this;
// bump z to avoid z order conflict and ensure item ray intersect priority
meh.position.z += base + (order * bump);
// create poly outline(s)
let outs = this.outs = [];
for (let p of [poly, ...(poly.inner || [])]) {
let lpt = p.points.map(p => new Vector3(p.x, p.y, p.z));
lpt.push(lpt[0]);
let lge = new BufferGeometry().setFromPoints(lpt);
let out = new THREE.Line(lge, material.wireline);
out.renderOrder = -1;
out.sketch_line = this;
out.position.z += base + (order * bump);
out.renderOrder = -10 + (bump * order);
outs.push(out);
}
}
}
export { MeshSketch as sketch, SketchItem };

147
src2/mesh/split.js Normal file
View file

@ -0,0 +1,147 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
/**
* toggle edit/split on Z tool
* presents a Z plane on hover
* click to split the selected mesh(es)
*/
import { THREE } from '../ext/three.js';
import { space as motoSpace } from '../moto/space.js';
import { broker as motoBroker } from '../moto/broker.js';
import { api as meshApi } from './api.js';
import { util as meshUtil } from './util.js';
const { Mesh, MeshPhongMaterial, PlaneGeometry, DoubleSide, Vector3 } = THREE;
let isActive;
const key_once = motoBroker.bind('key_once');
const key_cancel = motoBroker.bind('key_once_cancel');
// split functions
const split = {
active() {
return isActive ? true : false;
},
start() {
if (isActive) {
return;
}
let { log } = meshApi;
if (meshApi.selection.models().length === 0) {
log.emit('no models selected for splitting');
return;
}
// create split plane visual
let geo, mat, obj = new Mesh(
geo = new PlaneGeometry(1,1),
mat = new MeshPhongMaterial({
side: DoubleSide,
color: 0x5555aa,
transparent: false,
opacity: 0.5
})
);
motoSpace.scene.add(obj);
// hide until first hover
obj.visible = false;
// enable temp mode
let state = split.state = { obj };
let models = state.models = meshApi.selection.models();
let meshes = models.map(m => m.mesh);
// for split and lay flat modes
motoSpace.mouse.onHover((int, event, ints) => {
if (!event) {
return meshes;
}
obj.visible = false;
let { button, buttons } = event;
if (buttons) {
return;
}
let { dim, mid } = meshUtil.bounds(meshes);
let { point, face, object } = int;
let { x, y, z } = point;
// set appearance of split plane
mat.color.set(meshApi.prefs.map.space.dark ? 0x0059bb : 0x0079ff);
obj.visible = true;
if (event.shiftKey) {
y = split.closestZ(y, object, face).y;
}
// y is z in model space for the purposes of a split
state.plane = { z: y };
// ensure split plane exceeds bounds of split target
obj.scale.set(dim.x + 2, dim.y + 2, 1);
obj.position.set(mid.x, y, -mid.y);
});
isActive = true;
key_once({ code: 'KeyS', fn(evt) {
split.select();
}});
key_once({ code: 'KeyV', fn(evt) {
evt.preventDefault();
let state = split.state;
split.end();
function doit(z) {
state.plane = { z };
split.select(state);
}
meshApi.modal.dialog({
title: "split object Z",
body: [ h.div({ class: "additem" }, [
h.label('Z value'),
h.input({ value: 0, size: 5, id: "_value" }),
h.button({ _: "split", onclick() {
const { _value } = meshApi.modal.bound;
doit(parseFloat(_value.value));
meshApi.modal.hide();
} })
]) ]
});
meshApi.modal.bound._value.focus();
} });
},
select(state) {
let { log } = meshApi;
let { models, plane } = split.state || state;
if (!(models && plane)) {
return split.end();
}
log.emit(`splitting ${models.length} model(s) at ${plane.z.round(3)}`).pin();
Promise.all(models.map(m => m.split(plane))).then(models => {
meshApi.selection.set(models.filter(m => m));
log.emit('split complete').unpin();
split.end();
});
},
end() {
if (!isActive) {
return;
}
key_cancel('KeyS');
key_cancel('KeyV');
let { obj } = split.state;
motoSpace.scene.remove(obj);
motoSpace.mouse.onHover(undefined);
isActive = split.state = undefined;
meshApi.selection.update();
},
closestZ(z, object, face) {
let { position } = object.geometry.attributes;
let matrix = object.matrixWorld;
let v0 = new Vector3(position.getX(face.a), position.getY(face.a), position.getZ(face.a)).applyMatrix4(matrix);
let v1 = new Vector3(position.getX(face.b), position.getY(face.b), position.getZ(face.b)).applyMatrix4(matrix);
let v2 = new Vector3(position.getX(face.c), position.getY(face.c), position.getZ(face.c)).applyMatrix4(matrix);
v0._d = Math.abs(v0.y - z);
v1._d = Math.abs(v1.y - z);
v2._d = Math.abs(v2.y - z);
return [ v0, v1, v2 ].sort((a,b) => a._d - b._d)[0];
}
};
export { split };

889
src2/mesh/tool.js Normal file
View file

@ -0,0 +1,889 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { THREE } from '../ext/three.js';
const { Vector3 } = THREE;
import { geom } from './geom.js';
const empty = 0xffffffff;
/**
* tool for identiying defects and healing them
* copied from base/mesh which is used by KM. the
* two need to be merged. earcut needs to be migrated
* to base.util.triagulate
*/
class MeshTool {
constructor(params = {}) {
this.precision = Math.pow(10, params.precision || 6);
}
checkVertices(vertices, mod) {
if (!vertices) {
throw "missing vertices";
}
if (mod && vertices.length % mod !== 0) {
throw "invalid vertices";
}
return vertices;
}
getIndex() {
if (!this.indexed) {
throw "missing index";
}
return this.indexed;
}
listZ() {
let verts = this.checkVertices(this.uvert).group(3);
let zs = new Set();
for (let vert of verts) {
zs.add(vert[2]);
}
return Array.from(zs).sort((a,b) => a - b);
}
flattenZ(faceIdxs) {
let { uvert, faces } = this;
let zsum = 0;
let zunq = [];
for (let faceIdx of faceIdxs) {
let fv1 = faces[faceIdx * 3 + 0];
let fv2 = faces[faceIdx * 3 + 1];
let fv3 = faces[faceIdx * 3 + 2];
let z1 = uvert[fv1 + 2];
let z2 = uvert[fv2 + 2];
let z3 = uvert[fv3 + 2];
zunq.push(z1,z2,z3);
zsum = zsum + z1 + z2 + z3;
}
let zavg = ((( zsum / (faceIdxs.length * 3) ) * 10000 ) | 0) / 10000;
for (let faceIdx of faceIdxs) {
let fv1 = faces[faceIdx * 3 + 0];
let fv2 = faces[faceIdx * 3 + 1];
let fv3 = faces[faceIdx * 3 + 2];
uvert[fv1 + 2] = zavg;
uvert[fv2 + 2] = zavg;
uvert[fv3 + 2] = zavg;
}
return { unique: zunq.sort().uniq(), average: zavg };
}
/**
* @param {number[]} vertices non-indexed
*/
generateFaces(vertices, opt = {}) {
this.checkVertices(vertices, 3);
let doClean = opt.clean !== false;
let doDedup = opt.dedup !== false;
let round = Math.pow(10, opt.round || 0);
let faces = [];
let fcac = {}; // seen face hash
let fnew = []; // accumulate vertex triplets
let nuvt = []; // new vertex list
let hash = {}; // find vertex matches
let prec = this.precision;
let dups = 0;
let cull = 0;
if (round > 1) {
for (let i=0, l=vertices.length; i<l; i++) {
vertices[i] = ((vertices[i] * round) | 0) / round;
}
}
for (let i=0, l=vertices.length; i<l; i += 3) {
let x = vertices[i];
let y = vertices[i+1];
let z = vertices[i+2];
let key = [
(x * prec) | 0,
(y * prec) | 0,
(z * prec) | 0
].join('');
let vpos = hash[key];
if (vpos === undefined) {
// add new vertex x,y,z and hash entry with index
hash[key] = vpos = nuvt.length;
nuvt.push(x);
nuvt.push(y);
nuvt.push(z);
}
if (doClean) {
// add vertex position to face accumulator
fnew.push(vpos);
if (fnew.length === 3) {
// check and emit face if it's unique
// cull invalid faces (has 2 or more shared vertices)
if (fnew[0] === fnew[1] || fnew[0] === fnew[2] || fnew[1] === fnew[2]) {
fnew = [];
cull++;
continue;
}
if (doDedup) {
let key = fnew.slice().sort().join('-');
// drop duplicate faces (sort handles reverse order)
if (!fcac[key]) {
faces.appendAll(fnew);
fcac[key] = key;
} else {
dups++;
}
} else {
faces.appendAll(fnew);
}
fnew = [];
}
} else {
faces.push(vpos);
}
}
this.stats = {cull, dups, faces: faces.length/3};
// unique vertex array
this.uvert = nuvt;
// unique face array. elements are offset into vertices
this.faces = faces;
return this;
}
// todo: supercede generatedFaces() for patch()
// face record: vi1, vi2, vi3, nx, ny, nz
// vi = vertex index
// n = normal
// af = adjacent face index
index(vertices) {
this.vertices = this.checkVertices(vertices, 3);
const prec = this.precision;
const vcount = vertices.length / 3;
const fcount = vcount / 3;
const vround = vertices.map(v => (v * prec) | 0);
// side records [ vi0, vi1, fn1, fn2 ]
const sides = new Uint32Array(fcount * 4 * 3).fill(empty);
// map of side index to face count
const srecs = {};
// side extended records when face count exceeds 2 (bad mesh)
const sideExt = {};
// face record array [ nx, ny, nz, sn0, sn1, sn2 ]
const faces = new Float32Array(fcount * 6);
// vertex key to vertex index
const vimap = {};
// normal key to normal index
const nimap = {};
// side key to side index
const simap = {};
// tmp face vertex indices
const vinds = [ 0, 0, 0 ];
// tmp face raw vertex offset
const viraw = [ 0, 0, 0 ];
// tmp vector array
const vects = [ new Vector3(), new Vector3(), new Vector3() ];
// i=vround index, fi=faces record index
// vi=vinds index % 3, x,y,z = tmp vars
// fn=next face index, vn=next vertex index, sn=next side index
for (let i=0, l=vround.length, fn=0, vn=0, sn=0, fi=0, vi=0, x, y, z; i<l; ) {
const vroot = i;
// create unique vertex map
vects[vi].set(x = vround[i++], y = vround[i++], z = vround[i++]);
let key = x + ',' + y + ',' + z;
// vertex index
let vid = vimap[key];
if (vid === undefined) {
vid = vimap[key] = vn++;
}
viraw[vi] = vroot;
vinds[vi++] = vid;
// completed record for a face
if (vi === 3) {
// create indexed unique normal map
const cfn = THREE.computeFaceNormal(...vects);
const [ v0, v1, v2 ] = vinds;
// create consistent side order for index key
const s0 = (v0 < v1 ? v0 + "," + v1 : v1 + "," +v0);
const s1 = (v1 < v2 ? v1 + "," + v2 : v2 + "," +v1);
const s2 = (v2 < v0 ? v2 + "," + v0 : v0 + "," +v2);
// for storing raw vertex offset in side record
const [ vr0, vr1, vr2 ] = viraw;
// store face indexes into sdrec array for each side
const smap = [ s0, s1, s2 ].map((key,ki) => {
let sid = simap[key], sdoff, sdcnt;
if (sid === undefined) {
sid = simap[key] = sn++;
sdcnt = srecs[sid] = 1;
sdoff = sid * 4;
sides[sdoff] = viraw[ki];
sides[sdoff + 1] = viraw[(ki + 1) % 3];
} else {
sdoff = sid * 4;
sdcnt = ++srecs[sid];
}
if (sdcnt > 2) {
(sideExt[sid] = sideExt[sid] ||
[ sides[sdoff], sides[sdoff + 1], sides[sdoff + 2], sides[sdoff + 3] ]
).push(fn);
} else {
sides[sdoff + sdcnt + 1] = fn;
}
return sid;
});
faces[fi++] = cfn.x;
faces[fi++] = cfn.y;
faces[fi++] = cfn.z;
faces[fi++] = smap[0];
faces[fi++] = smap[1];
faces[fi++] = smap[2];
vi = 0;
fn++;
}
}
this.indexed = {
faces, sides, sideExt
};
}
getAdjacentFaces(face) {
const { faces, sides } = this.getIndex();
const foff = face * 6;
const s0 = faces[foff + 3];
const s1 = faces[foff + 4];
const s2 = faces[foff + 5];
const adj = [];
for (let s of [s0, s1, s2]) {
const soff = s * 4;
const f1 = sides[soff + 2];
const f2 = sides[soff + 3];
if (f1 !== empty) adj.push(f1);
if (f2 !== empty) adj.push(f2);
}
return adj;
}
findConnectedSurface(faces, radians, filterZ, found = {}) {
const norms = this.getIndex().faces;
if (filterZ !== undefined) {
// optional filter to z normal >= value
faces = faces.filter(f => norms[f * 6 + 2] >= filterZ);
}
const checked = {};
const check = faces.slice();
for (let face of faces) {
found[face] = 1;
}
while (check.length) {
const face = check.shift();
const froot = face * 6;
if (filterZ !== undefined && norms[froot + 2] < filterZ) {
continue;
}
const fadj = this.getAdjacentFaces(face);
for (let f of fadj) {
if (found[f] || checked[f]) {
continue;
}
const aroot = f * 6;
let sum = 0;
for (let i=0; i<3; i++) {
sum += Math.pow(norms[froot + i] - norms[aroot + i], 2);
}
const fn = Math.sqrt(sum);
if (fn <= radians) {
faces.push(f);
check.push(f)
checked[f] = 1;
found[f] = 1;
}
}
}
return faces;
}
generateOutlines(list) {
const { faces, sides } = this.getIndex();
const prec = this.precision;
const verts = this.vertices;
const lines = {};
const point = {};
const pindx = {};
let pnext = 0;
function vert2point(v) {
const x = verts[v];
const y = verts[v + 1];
const z = verts[v + 2];
const key = ((x * prec) | 0) + ',' + ((y * prec) | 0) + ',' + ((z * prec) | 0);
const rec = point[key];
if (rec === undefined) {
pindx[pnext] = { x, y, z, to: [] };
return point[key] = pnext++;
} else {
return rec;
}
}
function addLine(s) {
if (s === empty) return;
const v0 = vert2point(sides[s * 4]);
const v1 = vert2point(sides[s * 4 + 1]);
const key = v0 < v1 ? v0 + ',' + v1 : v1 +',' + v0;
const rec = lines[key];
if (rec) {
rec.del = true;
} else {
lines[key] = { v0, v1 };
}
}
for (let face of list) {
const foff = face * 6;
addLine(faces[foff + 3]);
addLine(faces[foff + 4]);
addLine(faces[foff + 5]);
}
const pairs = Object.values(lines).filter(l => !l.del);
for (let line of pairs) {
const { v0, v1 } = line;
const p0 = pindx[v0];
const p1 = pindx[v1];
p0.to.push(v1);
p1.to.push(v0);
}
let curr;
let outs = [ ];
for (let prec of Object.values(pindx)) {
if (prec.used || prec.to.length === 0) continue;
outs.push(curr = []);
while (prec) {
let { to, x, y, z } = prec;
if (to.length < 2) {
throw `invalid to ${to.length} @ ${x},${y},${z}`;
}
prec.used = true;
curr.push({ x, y, z });
const t0 = pindx[to[0]];
const t1 = pindx[to[1]];
if (!t0.used) {
prec = t0;
} else if (!t1.used) {
prec = t1;
} else {
prec = undefined;
}
}
}
// console.log({lines, pairs, pindx, outs});
return outs;
}
isolateBodies() {
const verts = this.checkVertices(this.vertices);
const bodies = [];
const used = {};
for (let i=0, l=verts.length / 9; i<l; i++) {
if (used[i]) {
continue;
}
const body = this.findConnectedSurface([i], Infinity, undefined, used);
if (body.length) {
// todo: pre-allocate known length
// then copy array regions from verts to bverts
const bverts = [];
for (let f of body) {
bverts.push(...verts.slice(f*9, f*9+9));
}
bodies.push(bverts);
}
}
return bodies;
}
patch(opt = { merge: true }) {
let vertices = this.checkVertices(this.uvert);
let faces = this.faces;
let hash = {}; // key to line map
let vmap = {}; // vertext to line map
let lines = [];
class Line {
constructor(v1, v2) {
this.v1 = v1;
this.v2 = v2;
this.faces = [];
}
addFace(face) {
if (this.faces.indexOf(face) < 0) {
this.faces.push(face);
} else if (false) {
console.log('adding face to line twice', this, face);
}
return this;
}
adjacentUnused() {
return vmap[this.v1]
.concat(vmap[this.v2])
.filter(l => l !== this && !l.used && l.faces.length === 1);
}
adjacentUsed() {
return vmap[this.v1]
.concat(vmap[this.v2])
.filter(l => l !== this && l.used && l.faces.length === 1);
}
touches(line) {
return this.v1 === line.v1 ||
this.v1 === line.v2 ||
this.v2 === line.v1 ||
this.v2 === line.v2;
}
unitVector() {
if (this.unit !== undefined) {
return this.unit;
}
let v1 = this.v1;
let v2 = this.v2;
let d = [
vertices[v1] - vertices[v2],
vertices[v1 + 1] - vertices[v2 + 1],
vertices[v1 + 2] - vertices[v2 + 2],
];
let max = Math.max(...d.map(v => Math.abs(v)));
// console.log({d, max});
return this.unit = d.map(v => v / max);
}
}
function diffUnitVector(uv1, uv2) {
let diff = 0;
for (let i=0; i<3; i++) {
diff += Math.abs(uv1[i] - uv2[i]);
}
return diff;
}
function getLine(v1, v2) {
let s1 = vertices[v1] + vertices[v1+1]*1000 + vertices[v1+2]*100000;
let s2 = vertices[v2] + vertices[v2+1]*1000 + vertices[v2+2]*100000;
let key = s1 <= s2 ? [v1,v2].join('-') : [v2,v1].join('-');
let line = hash[key];
if (!line) {
// point order will be swapped for reconstructed faces
// to preserve chirality (assuming face normals are correct)
line = hash[key] = new Line(v1, v2);
line.key = key;
lines.push(line);
let lm1 = vmap[v1] = vmap[v1] || [];
let lm2 = vmap[v2] = vmap[v2] || [];
lm1.push(line);
lm2.push(line);
}
return line;
}
function getXYZ(v) {
return {x: vertices[v], y: vertices[v+1], z: vertices[v+2]};
}
for (let i=0, l=this.faces.length; i<l; i += 3) {
let v1 = faces[i];
let v2 = faces[i+1];
let v3 = faces[i+2];
let l1 = getLine(v1, v2).addFace(i);
let l2 = getLine(v2, v3).addFace(i);
let l3 = getLine(v3, v1).addFace(i);
}
// array of unique line segment records with vertices
// and a list of faces. one = edge, two = inside
// all lines should be inside or object is not manifold
// but does not preclude self-intersecting geometries
this.lines = lines;
// lines belonging to only one face are on the edge of a hole
let edges = this.edges = lines.filter(l => l.faces.length === 1);
// arrays of connected edges that form a closed loop
let loops = this.loops = [];
// connect edge lines into closed loops
for (let i=0, l=edges.length; i<l; i++) {
let line = edges[i];
if (line.used) {
continue;
}
// cannot start on split
if (line.adjacentUnused().length > 2) {
// console.log('cannot start on a split');
continue;
}
// line.used = true;
let loop = [ ];
// build line through adjacent lines
while (true) {
let adjacent = line.adjacentUnused();
if (adjacent.length === 0) {
let first = loop[0];
let last = loop.last();
loop.open = loop.length < 3
|| (first.v1 !== last.v1
&& first.v2 !== last.v2
&& first.v1 !== last.v2
&& first.v2 !== last.v1);
// console.log({term: line, adj: line.adjacentUsed(), open, loop});
break;
} else if (adjacent.length === 1) {
if (line.v2 !== adjacent[0].v1) {
let tmp = adjacent[0].v1;
adjacent[0].v1 = adjacent[0].v2;
adjacent[0].v2 = tmp;
// console.log('chirality mismatch fixed');
}
line = adjacent[0];
} else if (adjacent.length === 2) {
// follow edges according to chirality
line = adjacent[0].v1 === line.v2 ?
adjacent[0] :
adjacent[1]
} else {
// console.log('error adjacent', line, adjacent, loops.length, loop.length);
line.split = adjacent;
line = adjacent[0];
}
loop.push(line);
line.used = true;
}
if (loop.length) {
loops.push(loop);
}
}
// attempt to connect open loops
if (loops.length > 1) {
outer: for (let i=0; i<loops.length-1; i++) {
let l1 = loops[i];
if (!(l1 && l1.open)) {
continue;
}
for (let j=i+1; j<loops.length; j++) {
let l2 = loops[j];
if (!(l2 && l2.open)) {
continue;
}
let nuloop;
if (l1[0].touches(l2[0])) {
nuloop = [...l2.reverse(), ...l1];
} else if (l1[0].touches(l2.last())) {
nuloop = [...l2, ...l1];
} else if (l1.last().touches(l2[0])) {
nuloop = [...l1, ...l2];
} else if (l1.last().touches(l2.last())) {
nuloop = [...l1, ...l2.reverse()];
}
if (nuloop) {
nuloop.open = true;
loops.push(nuloop);
loops[i] = loops[j] = undefined;
continue outer;
}
}
}
}
// store invalid loops for display / culling
this.shorts = loops.filter(l => l && l.length <= 2);
// console.log({edges, loops, shorts: this.shorts});
// filter out null and short loops
this.loops = loops = loops.filter(l => l && l.length > 2);
// console.log({loops_filtered: loops});
// rotate/flatten to Z plane and use earcut to generate faces
// because earcut emits point indexes, no need to un-rotate
function emitLoop(loop) {
let lastPoint;
let pindex = [];
let points = [];
let dx = 0, dy = 0, dz = 0;
// gather points, find axis with least deviaton to swap with Z
let loop1 = loop.slice();
loop1.push(loop[0]);
for (let i=0; i<loop1.length; i++) {
let line = loop1[i];
let nextPoint;
if (line.v1 === lastPoint) {
nextPoint = line.v2;
} else {
nextPoint = line.v1;
}
if (i < loop1.length - 1) {
pindex.push(nextPoint);
points.push([
vertices[nextPoint],
vertices[nextPoint+1],
vertices[nextPoint+2]
]);
}
if (lastPoint >= 0) {
dx = Math.max(dx, Math.abs(vertices[lastPoint] - vertices[nextPoint]));
dy = Math.max(dy, Math.abs(vertices[lastPoint+1] - vertices[nextPoint+1]));
dz = Math.max(dz, Math.abs(vertices[lastPoint+2] - vertices[nextPoint+2]));
}
lastPoint = nextPoint;
}
// swap Z axis with least delta axis
let swap = 2;
if (dy < dx && dy < dz) {
// swap y,z
swap = 1;
} else if (dx < dy && dx < dz) {
// swap x,z
swap = 0;
}
if (swap < 2) {
// swap x or y for z axes
for (let point of points) {
let tmpz = point[2];
point[2] = point[swap];
point[swap] = tmpz;
}
}
return { index: pindex, points: points.flat(), swap };
}
// given a loop with holes, emit faces
function emitFaces(loop) {
let { index, points } = loop;
let ec = earcut(points, undefined, 3);
// map earcut point indexes back to vertex indexes
return ec.map(p => index[p]);
}
function emitTop(rec) {
let points = rec.loop.points.slice();
let index = rec.loop.index.slice();
let holes = [];
for (let inner of rec.inner) {
holes.push(points.length / 3);
points.appendAll(inner.loop.points);
index.appendAll(inner.loop.index);
}
let ec = earcut(points, holes, 3);
return ec.map(p => index[p]);
}
// new patch areas
let areas = this.areas = [];
if (opt.compound) {
// group loops by normal (using swap as crude proxy for now)
// then attempt to nest them (find holes)
let polys = loops.map(loop => {
return emitLoop(loop);
});
// return nested mapping
let nested = geom.nest(polys.map(p => p.points));
// recover and map loops from point arrays
for (let rec of nested) {
rec.loop = polys.filter(loop => loop.points === rec.points)[0];
}
// extract tops which have nesting depth mod 2 === 0
let tops = nested.filter(rec => rec.depth % 2 === 0);
for (let top of tops) {
areas.push(emitTop(top));
}
} else {
// create area faces covering loops
for (let loop of loops) {
let l2 = emitLoop(loop);
let fs = emitFaces(l2);
areas.push(fs);
}
}
// auto-merge newly found enclosed areas
if (opt.merge) {
this.merge();
}
// de-reference loops and edges
if (opt.mapped) {
this.mapped = {
edges: this.expandLoop(this.edges),
areas: this.areas.map(a => this.expandArray(a))
};
}
return this;
}
appendVertex(index, array = []) {
let vertx = this.uvert;
array.push(vertx[index++]);
array.push(vertx[index++]);
array.push(vertx[index]);
return array;
}
expandArray(indices, array = []) {
for (let i of indices) {
this.appendVertex(i, array);
}
return array;
}
expandLoop(loop, array = []) {
for (let line of loop) {
this.appendVertex(line.v1, array);
this.appendVertex(line.v2, array);
}
return array;
}
merge() {
for (let area of this.areas || []) {
this.faces.appendAll(area);
}
}
unrolled() {
let out = [];
for (let face of this.faces) {
this.appendVertex(face, out);
}
return out;
}
generateGear(numTeeth, module, pressureAngle, offset) {
// Adapted from: Public Domain Parametric Involute Spur Gear by Leemon Baird, 2011, Leemon@Leemon.com http://www.thingiverse.com/thing:5505
// see also http://grabcad.com/questions/tutorial-how-to-model-involute-gears-in-solidworks-and-show-design-intent
// const pitch_radius = (numTeeth * module) / 2;
const pi = Math.PI;
// degrees to radians
function degrees_to_radians(theta) { return theta / 180 * pi; }
// polar to cartesian
function polar(r, theta) { return [r * Math.sin(theta), r * Math.cos(theta)]; }
// point on involute curve
function q6(b, s, t, d) { return polar(d, s * (iang(b, d) + t)); }
// unwind this many degrees to go from r1 to r2
function iang(r1, r2) { return Math.sqrt((r2 / r1) * (r2 / r1) - 1) - Math.acos(r1 / r2); }
// radius a fraction f up the curved side of the tooth
function q7(f, r, b, r2, t, s) { return q6(b, s, t, (1 - f) * Math.max(b, r) + f * r2); }
// lerp q7 0..1 with a given # steps
function qf(steps, r, b, r2, t, s) {
let res = [];
let step = 1/steps;
for (let f=0; f <= 1; f += step) {
res.push(q7(f, r, b, r2, t, s));
}
return res;
}
// rotate an array of 2d points
function rotate(points_array, angle) {
let answer = [];
for (let i = 0; i < points_array.length; i++) {
let x = points_array[i][0];
let y = points_array[i][1];
let xr = x * Math.cos(angle) - y * Math.sin(angle);
let yr = y * Math.cos(angle) + x * Math.sin(angle);
answer.push([xr, yr]);
}
return answer;
}
// gear parameter setup
let mm_per_tooth = module * pi; // mm size of one gear tooth
let clearance = offset/2 || 0; // freedom between two gear centers
let backlash = offset || 0; // freedom between two gear contact points
let pressure_angle = degrees_to_radians(pressureAngle);
let gear = [];
let pitch;
// involute gear maker
{
let p = mm_per_tooth * numTeeth / pi / 2; // radius of pitch circle
let c = p + mm_per_tooth / pi - clearance; // radius of outer circle
let b = p * Math.cos(pressure_angle); // radius of base circle
let r = p - (c - p) - clearance; // radius of root circle
let t = mm_per_tooth / 2 - backlash / 2; // tooth thickness at pitch circle
let k = -iang(b, p) - t / 2 / p; // angle where involute meets base circle on side of tooth
let f = 10 + ((p/5) | 0); // increase number of tooth curve points on larger gears
let points = [ // [x,y] points for a single gear tooth
polar(r, -pi / numTeeth),
polar(r, r < b ? k : -pi / numTeeth),
...qf(f, r, b, c, k, 1),
...qf(f, r, b, c, k, -1).reverse(),
polar(r, r < b ? -k : pi / numTeeth),
// polar(r, 3.142 / numTeeth) // omit b/c overlaps start when rotated
];
// create all gear teeth by rotating the first tooth
for (var i = 0; i < numTeeth; i++) {
gear.appendAll(rotate(points, -i * 2 * pi / numTeeth));
}
pitch = p.round(3);
geom.log(`gear pitch radius: ${pitch}`);
}
return { gear, pitch };
}
generateThreads(height, radius, turns, depth, steps, taper) {
let verts = [];
let zstep = height / turns;
let rstep = (Math.PI * 2) / steps;
let z2step = zstep / steps;
let points = []
let iter = 0;
let iter_max = turns * steps + steps;
let mute_min = Math.round(steps * 2.5);
let mute_max = iter_max - mute_min;
for (let turn=0; turn<turns; turn++) {
for (let step=0; step<steps; step++) {
iter++;
let itern = iter + steps;
let rm = radius - depth;
let rx = true &&
taper && iter < mute_min ? rm + (iter / mute_min) * depth :
taper && iter > mute_max ? rm + ((iter_max - iter) / mute_min) * depth :
radius;
let rxt = true &&
taper && itern < mute_min ? rm + (itern / mute_min) * depth :
taper && itern > mute_max ? rm + ((iter_max - itern) / mute_min) * depth :
radius;
let angle0 = rstep * step - Math.PI/2;
let z0 = (zstep * turn) + (z2step * step);
let x0 = Math.cos(angle0) * (rm);
let y0 = Math.sin(angle0) * (rm);
let x1 = Math.cos(angle0) * (rx);
let y1 = Math.sin(angle0) * (rx);
let x1t = Math.cos(angle0) * (rxt);
let y1t = Math.sin(angle0) * (rxt);
let p0 = [ x1t, y1t, z0 + zstep/2 ];
let p1 = [ x0, y0, z0 ];
let p2 = [ x1, y1, z0 - zstep/2 ];
let [ pp0, pp1, pp2 ] = points;
if (pp0 && pp1 && pp2) {
verts.push(...pp0, ...pp1, ...p0);
verts.push(...p0, ...pp1, ...p1);
verts.push(...pp1, ...pp2, ...p1);
verts.push(...p1, ...pp2, ...p2);
}
points = [ p0, p1, p2 ];
}
}
return verts;
}
}
export { MeshTool as tool };

335
src2/mesh/util.js Normal file
View file

@ -0,0 +1,335 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { THREE } from '../ext/three.js';
const { Matrix4, Matrix3, Vector3, Box3, LineSegments, BufferGeometry, Float32BufferAttribute, LineBasicMaterial } = THREE;
import { license as motoLicense } from '../moto/license.js';
import { api as meshApi } from './api.js';
import { object as meshObject } from './object.js';
const deferFn = [];
const boundsCache = {};
// util functions augmented in build (download)
const util = {
uuid(segs = 1) {
let uid = [];
while (segs-- > 0) {
uid.push(Math.round(Math.random() * 0xffffffff).toString(36));
}
return uid.join('-');
},
toHexRGB(v) {
return [
((v >> 16) & 0xff).toString(16).padStart(2,0),
((v >> 8) & 0xff).toString(16).padStart(2,0),
((v >> 0) & 0xff).toString(16).padStart(2,0)
].join('');
},
fromHexRGB(v) {
return 0 +
parseInt(v.substring(0,2), 16) << 16 |
parseInt(v.substring(2,4), 16) << 8 |
parseInt(v.substring(4,6), 16);
},
// add comma separator to 1000s
comma(val) {
let str = val.toString().split(".");
str[0] = str[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
return str.join(".");
},
// merge repeated function calls like updates
// that importantly take no arguments
defer(fn, time = 50) {
for (let rec of deferFn) {
if (rec.fn === fn) {
clearTimeout(rec.timer);
deferFn.remove(rec);
break;
}
}
let rec = { fn };
rec.timer = setTimeout(() => {
deferFn.remove(rec);
fn();
}, time);
deferFn.push(rec);
},
// return a function wrapper which can be re-used
deferWrap(fn, time) {
return function() {
util.defer(fn, time);
}
},
// @param object {THREE.Object3D | THREE.Object3D[] | MeshObject | MeshObject[]}
// @returns bounds modified for moto.space
bounds(object) {
let box = new Box3();
if (Array.isArray(object)) {
for (let o of object) {
util.box3expand(box, o instanceof meshObject ? o.object : o);
}
} else if (object) {
util.box3expand(box, object instanceof meshObject ? object.object : object);
} else {
return box;
}
let bnd = {
min: {
x: box.min.x,
y: box.min.z,
z: box.min.y
},
max: {
x: box.max.x,
y: box.max.z,
z: box.max.y
}
};
bnd.size = bnd.dim = {
x: bnd.max.x - bnd.min.x,
y: bnd.max.y - bnd.min.y,
z: bnd.max.z - bnd.min.z
};
bnd.center = bnd.mid = {
x: (bnd.max.x + bnd.min.x) / 2,
y: -(bnd.max.y + bnd.min.y) / 2,
z: (bnd.max.z + bnd.min.z) / 2
};
return bnd;
},
// bounding box workaround adapted from:
// https://discourse.threejs.org/t/bounding-box-bigger-than-concave-object-extrudegeometry/26073/2
// https://discourse.threejs.org/t/invalid-bounds-generated-for-some-orientations/33205
box3expand(box3, object) {
if (object._no_bounds) {
return;
}
let geometry = object.geometry;
object.updateWorldMatrix(geometry ? true : false, false);
if (geometry) {
let matrix = object.matrixWorld;
let bkey = [matrix.elements.map(v => v.round(5))].join(',')
let cached = boundsCache[object.id];
// geometry._model_invalid set on model.reload(), usually after a split
if (!cached || cached.bkey !== bkey || geometry._model_invalid) {
let position = geometry.attributes.position.clone();
position.applyMatrix4(new Matrix4().extractRotation(matrix));
let bounds = new Box3().setFromBufferAttribute(position);
// let scale = new Vector3().setFromMatrixScale(matrix);
// bounds.min.multiply(scale);
// bounds.max.multiply(scale);
cached = boundsCache[object.id] = { bkey, bounds };
geometry._model_invalid = undefined;
}
let bt = cached.bounds.clone();
let m4 = new Matrix4();
m4.setPosition(new Vector3().setFromMatrixPosition(object.matrixWorld));
bt.applyMatrix4(m4);
box3.union(bt);
}
let children = object.children;
for (let i = 0, l = children.length; i < l; i++) {
util.box3expand(box3, children[i]);
}
},
// extract object fields into an array with optional rounding
extract(object, opt = {}) {
let field = opt.fields || ['x', 'y', 'z'];
let array = [];
for (let k of field) {
let v = object[k] || 0;
if (opt.round !== undefined) v = v.round(opt.round);
if (opt.fixed !== undefined) v = v.toFixed(opt.fixed);
array.push(v);
}
return opt.map ?
{
[field[0]] : array[0],
[field[1]] : array[1],
[field[2]] : array[2],
} :
array;
},
// for an array of maps, return the average of all named fields in a new map
average(array, opt = {}) {
let fields = opt.fields || ['x', 'y', 'z'];
let avg = {};
for (let e of array) {
for (let f of fields) {
avg[f] = (avg[f] || 0) + e[f];
}
}
for (let f of fields) {
avg[f] = (avg[f] || 0) / array.length;
}
return avg;
},
faceNormals(obj, opt = { }) {
const _va = new Vector3();
const _vb = new Vector3();
const _vc = new Vector3();
const _v1 = new Vector3();
const _v2 = new Vector3();
const _normalMatrix = new Matrix3();
const prefs = meshApi.prefs.map;
const norms = prefs.normals;
const defcolor = prefs.space.dark ? norms.color_dark : norms.color_lite;
const normlen = norms.length || 1;
class FaceNormalsHelper extends LineSegments {
constructor(object, size = opt.size || normlen, color = opt.color || defcolor) {
const objGeometry = object.geometry;
const nNormals = objGeometry.attributes.position.count / 3;
const geometry = new BufferGeometry();
const positions = new Float32BufferAttribute(nNormals * 3 * 2, 3);
geometry.setAttribute('position', positions);
super(geometry, new LineBasicMaterial({
color, toneMapped: false
}));
this.object = object;
this.size = size;
this.type = 'FaceNormalsHelper';
this.matrixAutoUpdate = false;
this._no_bounds = true;
this.update();
}
update() {
this.object.updateMatrixWorld(true);
const position = this.geometry.attributes.position;
const objGeometry = this.object.geometry;
const objArr = objGeometry.attributes.position.array;
let j=0;
for (let idx = 0, xj = 0, jl = objArr.length; j < jl; ) {
_va.set(objArr[j++], objArr[j++], objArr[j++]);
_vb.set(objArr[j++], objArr[j++], objArr[j++]);
_vc.set(objArr[j++], objArr[j++], objArr[j++]);
let vn = THREE.computeFaceNormal(_va, _vb, _vc);
let vc = _va.add(_vb).add(_vc).divideScalar(3);
_v2.copy(vc).add(vn.multiplyScalar(this.size));
position.setXYZ(idx++, vc.x, vc.y, vc.z);
position.setXYZ(idx++, _v2.x, _v2.y, _v2.z);
}
position.needsUpdate = true;
}
}
return new FaceNormalsHelper(obj);
},
vertexNormals(mesh) {
const _v1 = new Vector3();
const _v2 = new Vector3();
const _normalMatrix = new Matrix3();
class VertexNormalsHelper extends LineSegments {
constructor(object, size = 1, color = 0xff0000) {
const objGeometry = object.geometry;
const nNormals = objGeometry.attributes.normal.count;
const geometry = new BufferGeometry();
const positions = new Float32BufferAttribute(nNormals * 2 * 3, 3);
geometry.setAttribute('position', positions);
super(geometry, new LineBasicMaterial({
color, toneMapped: false
}));
this.object = object;
this.size = size;
this.type = 'VertexNormalsHelper';
this.matrixAutoUpdate = false;
this._no_bounds = true;
this.update();
}
update() {
this.object.updateMatrixWorld(true);
const position = this.geometry.attributes.position;
const objGeometry = this.object.geometry;
const objArr = objGeometry.attributes.position.array;
const normArr = objGeometry.attributes.normal.array;
let j=0;
for (let idx = 0, jl = objArr.length; j < jl; ) {
_v1.set(objArr[j++], objArr[j++], objArr[j++]);
_v2.set(normArr[j-3], normArr[j-2], normArr[j-1]);
_v2.multiplyScalar(this.size).add(_v1);
position.setXYZ(idx++, _v1.x, _v1.y, _v1.z);
position.setXYZ(idx++, _v2.x, _v2.y, _v2.z);
}
position.needsUpdate = true;
}
}
return new VertexNormalsHelper(mesh);
},
facesToGroups(faces) {
let groups = [];
if (faces && faces.length) {
faces = faces.sort((a,b) => a - b).slice();
let first = faces.shift();
if (first > 0) {
groups.push({ start: 0, count: first, mat: 0 });
}
let range = { start: first, count: 1, mat: 1 };
groups.push(range);
for (let face of faces) {
if (face === range.start + range.count) {
range.count++;
} else {
groups.push(range = { start: range.start + range.count, count: face - range.start - range.count });
groups.push(range = { start: face, count: 1, mat: 1 });
}
}
groups.push({ start: range.start + range.count, count: Infinity });
} else {
groups.push({ start: 0, count: Infinity });
}
return groups;
},
toSharedF32(float32) {
if (float32.buffer instanceof SharedArrayBuffer) {
return float32;
}
let { buffer } = float32;
let shared = new Float32Array(new SharedArrayBuffer(buffer.byteLength));
shared.set(float32);
return shared;
},
toLocal32(shared32) {
if (!(shared32.buffer instanceof SharedArrayBuffer)) {
return shared32;
}
let local = new Float32Array(shared32.length);
local.set(shared32);
return local;
},
diagram(object, depth = 0) {
let pre = '|'.padStart(depth * 3, '-');
let ent = Object.entries(object).sort((a,b) => a[0] > b[0] ? 1 : -1);
for (let [key, v] of ent) {
console.log(pre, key);
if (v && typeof v === 'object' && !Array.isArray(v)) {
util.diagram(v, depth + 1);
}
}
}
};
export { util };

574
src2/mesh/work.js Normal file
View file

@ -0,0 +1,574 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { THREE } from '../ext/three.js';
const { Triangle, Vector3, BufferGeometry, BufferAttribute, computeFaceNormal } = THREE;
import { license as motoLicense } from '../moto/license.js';
import { client as motoClient } from '../moto/client.js';
import { worker as motoWorker } from '../moto/worker.js';
import { util as meshUtil } from './util.js';
import { tool as meshTool } from './tool.js';
import { CSG, newPoint, newPolygon, sliceConnect, polygons } from '../geo/base.js';
import { line as geoLine } from '../geo/line.js';
import { point as geoPoint } from '../geo/point.js';
import { polygon as geoPolygon } from '../geo/polygon.js';
import { polygons as geoPolygons } from '../geo/polygons.js';
import { bounds as geoBounds } from '../geo/bounds.js';
import { slicer as geoSlicer } from '../geo/slicer.js';
import { csg as geoCsg } from '../geo/csg.js';
import { broker } from '../moto/broker.js';
import { api } from './api.js';
import { model } from './model.js';
const cache = {};
// add scoped access to cache
const work = { cache };
// start worker pool (disabled for now with *0)
motoClient.start(`/code/mesh_pool?${license.version}`, motoClient.max() * 0);
function log(msg) {
return motoWorker.publish("mesh.log", msg);
}
function cacheUpdate(id, data) {
return Object.assign(cache[id], data);
}
// translate original mesh vertices into UI world view (PI/2 rotation on X)
function translate_encode(id) {
let rec = cache[id];
let { pos } = rec;
let mkey = pos.map(v => v.round(6)).join('-');
// re-translate on missing cache or changed position
if (!rec || !rec.trans || rec.mkey !== mkey) {
let geo = rec.geo.clone().translate(new Vector3().fromArray(pos));
rec.mkey = mkey;
rec.trans = geo.attributes.position.array
}
return rec.trans;
}
function analyze(id, opt = {}) {
let rec = cache[id];
let { geo, tool } = rec;
if (!tool) {
tool = rec.tool = new meshTool(opt);
}
if (tool.faces) {
log(`${id} | analysis cached`);
} else {
log(`${id} | analyzing...`);
tool.generateFaces(geo.attributes.position.array, opt);
log(`${id} | patching...`);
tool.patch(opt);
}
return tool;
}
function isolateBodies(id) {
let tool = indexFaces(id);
log(`${id} | isolating bodies`);
return tool.isolateBodies();
}
function indexFaces(id) {
let rec = cache[id];
let { geo, tool } = rec;
if (!tool) {
tool = rec.tool = new meshTool();
}
if (!tool.indexed) {
log(`${id} | indexing mesh`);
tool.index(geo.attributes.position.array);
}
return tool;
}
// return an array of edges on the open spaces left by the split
// to be joined into polys, earcut, and turned into patching faces
function splitFindEdges(z, o1, o1p) {
let edges = [];
// find unshared edges on Z plane
o1p.forEach((face, i) => {
face = face.filter(v => v.z === z);
if (face.length >= 2) {
edges.push({ p1: face[0], p2: face[1], i });
}
if (face.length > 2) {
edges.push({ p1: face[1], p2: face[2], i });
edges.push({ p1: face[2], p2: face[0], i });
}
});
o1.forEach((face, i) => {
face = face.filter(v => v.z === z);
if (face.length >= 2) {
edges.push({ p1: face[0], p2: face[1] });
}
if (face.length > 2) {
edges.push({ p1: face[1], p2: face[2] });
edges.push({ p1: face[2], p2: face[0] });
}
});
// eliminate edges that show up twice since it means they're shared
outer: for (let i = 0, l = edges.length; i < l; i++) {
for (let j = i + 1; j < l; j++) {
let e1 = edges[i];
let e2 = edges[j];
if (!(e1 && e2)) {
continue;
}
let m1 = (e1.p1 === e2.p1 && e1.p2 === e2.p2);
let m2 = (e1.p2 === e2.p1 && e1.p1 === e2.p2);
if (m1 || m2) {
edges[i] = edges[j] = undefined;
continue outer;
}
}
}
return edges.filter(e => e);
}
function splitHeal(z, o1, edges, rev) {
// filter and convert Vector3 to Point for sliceConnect()
edges = edges.filter(e => e).map(e => {
return {
p1: newPoint().move(e.p1),
p2: newPoint().move(e.p2),
}
});
if (edges.length) {
// heal unshared edges created along Z split
// normals (from point array) are reversed for the bottom split
let heal = polygons.nest(sliceConnect(edges, z, { dirty: true }));
let ear = heal.map(poly => poly.earcut()).flat();
let o1p = ear.map(poly => {
return (rev ? poly.points.reverse() : poly.points).map(p => [ p.x, p.y, p.z ])
});
o1.appendAll(o1p.flat().flat());
// console.log({ edges, heal, ear, o1 });
}
return edges;
}
const model = {
load(data) {
let { vertices, name, id } = data;
let geo = new BufferGeometry();
geo.setAttribute('position', new BufferAttribute(vertices, 3));
cacheUpdate(id, { name, geo, mkey: undefined, tool: undefined });
},
// merge several model vertices into a single array
merge(recs) {
let arrays = recs.map(rec => translate_encode(rec.id, rec.matrix));
let length = arrays.map(a => a.length).reduce((v,a) => v + a);
let data = new Float32Array(length);
for (let i=0, l=arrays.length, p=0; i<l; ) {
data.set(arrays[i], p);
p += arrays[i++].length;
}
return data;
},
union(ids) {
let arrays = ids.map(id => translate_encode(id));
let solids = arrays.map(a => CSG.fromPositionArray(a));
let union = CSG.union(...solids);
return CSG.toPositionArray(union);
},
difference(ids) {
let arrays = ids.map(id => translate_encode(id));
let solids = arrays.map(a => CSG.fromPositionArray(a));
let diff = CSG.difference(...solids);
return CSG.toPositionArray(diff);
},
intersect(ids) {
let arrays = ids.map(id => translate_encode(id));
let solids = arrays.map(a => CSG.fromPositionArray(a));
let union = CSG.intersect(...solids);
return CSG.toPositionArray(union);
},
subtract(recs) {
let bases = recs.filter(rec => !rec.tool)
.map(rec => translate_encode(rec.id))
.map(a => CSG.fromPositionArray(a));
let tools = recs.filter(rec => rec.tool)
.map(rec => translate_encode(rec.id))
.map(a => CSG.fromPositionArray(a));
let diff = CSG.difference(...bases, ...tools);
return CSG.toPositionArray(diff);
},
// split a model along an axis at a given point
// return two arrays of vertices for each resulting object
split(data) {
let { id, z } = data;
let scale = 100000;
z = Math.round(z * scale) | 0;
let pos = data.pos || translate_encode(id);
let o1 = []; // new bottom
let o2 = []; // new top
let o1p = []; // o1 new split faces
let o2p = []; // o2 new split faces
let on = [];
let over = [];
let under = [];
let cache = {}; // vertex dedup cache
function sort(v) {
if (v.z < z) return under.push(v);
if (v.z > z) return over.push(v);
on.push(v);
}
function lerp(v1, v2) {
let zd = Math.abs(v1.z - v2.z);
let z1 = Math.abs(v1.z - z);
let v3 = v1.clone().lerp(v2, z1/zd);
return newV(v3.x/scale, v3.y/scale, v3.z/scale);
}
function newV() {
let args = [...arguments].map(v => Math.round(v * scale) | 0);
let key = args.join(':');
let cached = cache[key];
if (!cached) {
let [ x, y, z ] = args;
cache[key] = cached = new Vector3(x,y,z);
} else {
// console.log('cache hit', cached, key);
}
return cached;
}
// todo put proposed faces into top or bottom arrays
// check proposed faces that have one point shared on only two edges
// and if they're coplanar, merge them. these points cause non-manifold
for (let i=0, l=pos.length; i<l; ) {
on.length = over.length = under.length = 0;
let v1 = newV(pos[i++], pos[i++], pos[i++]);
let v2 = newV(pos[i++], pos[i++], pos[i++]);
let v3 = newV(pos[i++], pos[i++], pos[i++]);
sort(v1);
sort(v2);
sort(v3);
let onl = on.length;
let overl = over.length;
let underl = under.length;
let isover = (onl + overl === 3);
let isunder = (onl + underl === 3);
if (onl === 3) {
// co-planar
let tri = new Triangle(...on);
let norm = tri.getNormal(new Vector3());
if (norm.z > 0) {
isover = false;
isunder = true;
} else {
isover = true;
isunder = false;
}
}
if (isover) {
// all points on or over
o2.push([ v1, v2, v3 ]);
} else if (isunder) {
// all points on or under
o1.push([ v1, v2, v3 ]);
} else {
let g1, g2, oa, ua;
if (overl === 2) {
// two over, one under
g1 = o2p;
g2 = o1p;
oa = over;
ua = under;
} else if (underl === 2) {
// two under, one over
g1 = o1p;
g2 = o2p;
oa = under;
ua = over;
} else if (onl === 1) {
// one on, one over, one under
let p1 = over[0];
let p2 = on[0];
let p3 = under[0];
let p4 = lerp(p1, p3);
let cw = (v1 === p1 && v2 === p2)
|| (v1 === p2 && v2 === p3)
|| (v1 === p3 && v2 === p1);
// clockwise vs counter-clockwise
if (cw) {
o1p.push([ p2, p3, p4 ]);
o2p.push([ p1, p2, p4 ]);
} else {
o1p.push([ p3, p2, p4 ]);
o2p.push([ p2, p1, p4 ]);
}
continue;
}
let [ p1, p2 ] = oa;
let p3 = ua[0] || on[0]; // lone point
let m1 = lerp(p1, p3);
let m2 = lerp(p2, p3);
if (v2 === ua[0]) {
// reverse when the mid point gap
g1.push([ m1, p2, p1 ]);
g1.push([ m1, m2, p2 ]);
g2.push([ p3, m2, m1 ]);
} else {
g1.push([ p1, p2, m1 ]);
g1.push([ p2, m2, m1 ]);
g2.push([ m1, m2, p3 ]);
}
}
}
let e1 = splitFindEdges(z, o1, o1p);
let e2 = splitFindEdges(z, o2, o2p);
// merge o1p and o2p into o1 and o2
o1.appendAll(o1p.filter(e => e));
o2.appendAll(o2p.filter(e => e));
// flatten output points to arrays
o1 = o1.flat().map(e => [ ...e ]).flat();
o2 = o2.flat().map(e => [ ...e ]).flat();
// // filter and convert Vector3 to Point for sliceConnect()
splitHeal(z, o1, e1);
splitHeal(z, o2, e2, true);
return {
o1: o1.map(v => v/scale).toFloat32(),
o2: o2.map(v => v/scale).toFloat32()
};
},
analyze(data) {
let { id, opt } = data;
let tool = analyze(id, { mapped: true, ...opt });
let { stats, mapped } = tool;
let { cull, dups, faces } = stats;
log(`${id} | face count=${faces} cull=${cull} dup=${dups}`);
log(`${id} | open loops=${tool.loops.length} edges=${tool.edges.length}`);
return { stats, mapped };
},
heal(data) {
let { id, opt } = data;
let tool = analyze(id, opt);
log(`${id} | unrolling...`);
let zlist = tool.listZ();
let unrolled = tool.unrolled();
return { vertices: unrolled.toFloat32(), zlist };
},
flatten(data) {
let { id, faces } = data;
let tool = analyze(id, { });
log(`${id} | flatten...`);
let { unique, average } = tool.flattenZ(faces);
log(`${id} | average=${average.round(3)} uniques=${unique.length}`);
let unrolled = tool.unrolled();
return { vertices: unrolled.toFloat32() };
},
indexFaces(data) {
let { id, opt } = data;
let tool = indexFaces(id);
return { mapped: true };
},
isolate(data) {
let { id } = data;
return isolateBodies(id);
},
// given model and point, locate matching vertices, lines, and faces
select(data) {
let { id, x, y, z, a, b, c, surface } = data;
let { radians, radius, filterZ } = surface;
const rec = cache[id];
const arr = rec.geo.attributes.position.array;
// distance tolerance for click to vertex (rough distance)
const eps = radius || 0.2;
const faces = [];
const verts = [];
const edges = [];
let point;
if (!radians)
for (let i=0, l=arr.length; i<l; ) {
// matches here are within radius of a vertex
// select all faces that share a matched vertex
let vert = i/3; // vertex index
let face = (i/9) | 0; // face index
let ax = arr[i++];
let ay = arr[i++];
let az = arr[i++];
let dx = Math.abs(ax - x);
let dy = Math.abs(ay - y);
let dz = Math.abs(az - z);
if (dx < eps && dy < eps && dz < eps) {
faces.addOnce(face);
verts.push(vert);
// console.log(`match @ ${i-3} = ${face}`, ax, ay, az);
}
}
// no matches and we look at the line segments from the provided face
// to see if x,y,z point was on or near that line. then select the
// two faces shared by that line
if (faces.length === 0) {
// todo or not todo
}
// if no lines match, select the provided face (from min vertex index)
if (faces.length === 0) {
faces.push(Math.min(a,b,c) / 3);
}
// if the geometry has indexed faces and radians are set, find surface
const tool = rec.tool;
if (tool && tool.indexed && radians) {
const match = tool.findConnectedSurface(faces, radians, filterZ);
return { faces: match, edges, verts, point };
}
return { faces, edges, verts, point };
},
gen_gear(data, send) {
const { teeth, module, angle, twist, shaft, offset, height, chamfer } = data;
const { gear, pitch } = new meshTool().generateGear(teeth, module, angle, offset);
const points = gear.map(v => [...v, 0]).flat();
const poly = newPolygon().addVerts(points);
if (shaft) {
const nump = Math.min(30 + shaft, 150);
const srad = shaft / 2;
poly.addInner( newPolygon().centerCircle({ x:0, y:0, z:0 }, srad, nump) );
}
const zh = height || 15;
const verts = poly.extrude(zh, { chamfer });
if (twist) {
const rad = base.util.toRadians(twist);
for (let i=0; i<verts.length; i += 3) {
let [x, y] = base.util.rotate(
verts[i],
verts[i+1],
rad * (verts[i+2] / zh)
);
verts[i] = x;
verts[i+1] = y;
}
}
send.done(verts);
},
gen_threads(data, send) {
let { height, radius, turns, depth, steps, taper } = data;
let zstep = height / turns;
height += zstep * 2;
turns += 2;
let verts = new meshTool().generateThreads(
height,
radius,
turns,
depth,
steps,
taper
);
// return send.done(verts);
let s0 = model.split({
z: zstep,
pos: verts
})
let s1 = model.split({
z: height - zstep,
pos: s0.o2
});
send.done(s1.o1);
},
add(data) {
let { id, model } = data;
},
remove(data) {
let { id, model } = data;
},
meta(data) {
let { id, meta } = data;
cacheUpdate(id, meta);
},
create(data) {
let { id, type } = data;
cache[id] = { id, type };
},
destroy(data) {
delete cache[data.id];
},
export(data, send) {
let header = `# Generated By Mesh:Tool @ https://grid.space/mesh (units = millimeters)`;
let { format, recs } = data;
let vtot = 0;
for (let rec of recs) {
let { id } = rec;
let vs = rec.varr = Array.from(translate_encode(id)).map(v => v.round(5));
vtot += (vs.length / 3);
}
switch (format) {
case "obj":
let p = 1;
let obj = [header];
for (let rec of recs) {
let { file, varr } = rec;
obj.push(`g ${file}`);
for (let i=0; i<varr.length; p += 3) {
obj.push(`v ${varr[i++]} ${varr[i++]} ${varr[i++]}`);
obj.push(`v ${varr[i++]} ${varr[i++]} ${varr[i++]}`);
obj.push(`v ${varr[i++]} ${varr[i++]} ${varr[i++]}`);
obj.push(`f ${p} ${p+1} ${p+2}`);
}
}
return obj.join('\n');
case "stl":
let stl = new Uint8Array(80 + 4 + vtot/3 * 50);
let dat = new DataView(stl.buffer);
let pos = 84;
header.split('').forEach((c,i) => {
dat.setUint8(i, c.charCodeAt(0));
});
// todo put Mesh:Tool info in header
dat.setInt32(80, vtot/3, true);
for (let rec of recs) {
let { id, matrix, file, varr } = rec;
for (let i=0, l=varr.length; i<l;) {
let p0 = new Vector3(varr[i++], varr[i++], varr[i++]);
let p1 = new Vector3(varr[i++], varr[i++], varr[i++]);
let p2 = new Vector3(varr[i++], varr[i++], varr[i++]);
let norm = computeFaceNormal(p0, p1, p2);
dat.setFloat32(pos + 0, norm.x, true);
dat.setFloat32(pos + 4, norm.y, true);
dat.setFloat32(pos + 8, norm.z, true);
dat.setFloat32(pos + 12, p0.x, true);
dat.setFloat32(pos + 16, p0.y, true);
dat.setFloat32(pos + 20, p0.z, true);
dat.setFloat32(pos + 24, p1.x, true);
dat.setFloat32(pos + 28, p1.y, true);
dat.setFloat32(pos + 32, p1.z, true);
dat.setFloat32(pos + 36, p2.x, true);
dat.setFloat32(pos + 40, p2.y, true);
dat.setFloat32(pos + 44, p2.z, true);
pos += 50;
}
}
return stl;
default:
throw `invalid format "${format}"`;
}
}
};
function debug() {
console.log({work_cache: cache});
}
export { work, log, model, debug };

75
src2/moto/ajax.js Normal file
View file

@ -0,0 +1,75 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { local } from '../data/local.js';
function rnd() {
return Math.round(Math.random()*0xffffffff).toString(36);
}
const KEY = "moto-ajax";
const MOID = local.getItem(KEY) || (Date.now().toString(36)+rnd()+rnd());
local.setItem(KEY, MOID);
const STATES = [
"request not initialized", // 0
"server connection established", // 1
"request recieved", // 2
"processing request", // 3
"request complete" // 4
];
class Ajax {
constructor(callback, responseType) {
this.ajax = new XMLHttpRequest();
this.ajax.onreadystatechange = this.onStateChange.bind(this);
this.ajax.withCredentials = true;
this.state = STATES[0];
this.callback = callback;
this.responseType = responseType;
}
onStateChange() {
this.state = STATES[this.ajax.readyState];
if (this.ajax.readyState === 4 && this.callback) {
let status = this.ajax.status;
if (status >= 200 && status < 300) {
this.callback(this.ajax.responseType ? this.ajax.response : this.ajax.responseText, this.ajax);
} else {
this.callback(null, this.ajax);
}
}
}
request(url, send, headers) {
this.ajax.open(send ? "POST" : "GET", url, true);
if (this.responseType) this.ajax.responseType = this.responseType;
headers = headers || {};
headers["X-Moto-Ajax"] = MOID;
for (let k in headers) {
this.ajax.setRequestHeader(k, headers[k]);
}
if (send) {
this.ajax.send(send);
} else {
this.ajax.send();
}
return this;
}
}
function call(url, handler) {
return new Ajax(handler).request(url);
}
function restore(id) {
MOID = id;
local.setItem(KEY, MOID);
}
export const ajax = {
new: (cb, rt) => new Ajax(cb, rt),
call,
restore,
id: MOID
};

136
src2/moto/broker.js Normal file
View file

@ -0,0 +1,136 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
class Broker {
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
// and guarding against typos in topic names
}
topics() {
return Object.keys(this.topics);
}
// create subscriptions for all functions in an object
// a complement wrapObject on the subscription side
listeners(object, root) {
for (let [key, fn] of Object.entries(object)) {
if (typeof fn === 'function') {
key = root ? `${root}_${key}` : key;
this.subscribe(key, fn);
}
}
}
// attach function to a topic
// creates topic if new topic
// creates a send function if new topic
subscribe(topic, listener) {
if (Array.isArray(topic)) {
for (let t of topic) {
this.subscribe(t, listener);
}
return this;
}
if (typeof topic !== 'string') {
console.trace({invalid_topic: topic});
return;
}
let topics = this.topics;
let channel = topics[topic];
if (!channel) {
channel = topics[topic] = [];
}
if (channel.indexOf(listener) < 0) {
let send = this.send;
let name = topic.replace(/[\\ \.-]/g, '_');
send[name] = send[name] = this.bind(topic);
channel.push(listener);
this.publish(".topic.add", topic);
}
return this;
}
unsubscribe(topic, listener) {
let channel = this.topics[topic];
if (!channel) {
return;
}
let index = channel.indexOf(listener);
if (index < 0) {
return;
}
channel.splice(index,1);
if (channel.length === 0) {
delete this.topics[topic];
this.publish(".topic.remove", topic);
}
}
publish(topic, message, options = {}) {
if (Array.isArray(topic)) {
for (let t of topic) {
this.publish(t, message, options);
}
return;
}
if (topic !== ".topic.publish") {
this.publish(".topic.publish", {topic, message, options});
}
// store last seen message on a topic
// acts as a tracker for all used topics
this.used[topic] = message;
let channel = this.topics[topic];
if (channel && channel.length) {
for (let listener of channel) {
try {
listener(message, topic, options);
} catch (e) {
// console.log({listener_error: e, topic, options, listener});
setTimeout(() => { throw e }, 1);
}
}
} else if (topic.indexOf(".topic.") < 0) {
if (self.dbug) {
self.dbug.debug(`undelivered '${topic}'`, message);
} else {
console.log(`undelivered '${topic}'`, message);
}
}
return message;
}
// return function bound to a topic for publishing
bind(topic, message, options) {
let broker = this;
return function(msg, opt) {
broker.publish(topic, message || msg, options || opt);
}
}
// return function wrapper that publishes a message to a topic
// the published message is the return data from the function call
wrap(topic, fn) {
let broker = this;
return function() {
return broker.publish(topic, fn(...arguments));
}
}
// bind all functions in an object to a topic root
// functions are replaced with wrappers that publish call results
// a natural use case is wrapping api objects
wrapObject(object, root) {
for (let [key, fn] of Object.entries(object)) {
if (typeof fn === 'function') {
object[key] = this.wrap(`${root}_${key}`, fn);
}
}
}
}
export const broker = new Broker();

253
src2/moto/client.js Normal file
View file

@ -0,0 +1,253 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
"use strict";
import { broker } from './broker.js';
let ccvalue = self.navigator ? navigator.hardwareConcurrency || 1 : 1,
ccmax = ccvalue > 3 ? ccvalue - 1 : 1,
workcc = false, // concurrent or not
workurl = null, // url to load worker
worknum = 1, // number of workers to start
workers = [], // array of all workers
queue = [], // array of work requests
topics = {}; // topic handler (broker lite for worker > client msgs)
function dispatch(topic, message) {
for (let fn of topics[topic] || []) {
fn(message);
}
}
// code is running in the browser / client context
const client = {
/** @returns [integer] total number of workers **/
live: () => {
return workers.length;
},
/** @returns [integer] number of idle workers **/
free: () => {
return workers.filter(w => w.run === null).length;
},
/** @returns [integer] max cpu cores available **/
max: () => {
return ccmax;
},
/** process worker topic messages like "ready" **/
on: (topic, fn) => {
(topics[topic] = topics[topic] || []).addOnce(fn);
},
/**
* @returns {Object} registered worker function map
* auto-filled when worker process uses bind() or bindObject()
**/
fn: {},
/** binds a function endpoint to a task name **/
bind: (task) => {
if (!client.fn[task]) {
client.fn[task] = (data, options) => {
return client.call(task, data, options);
};
}
},
/** add a promised task call to the work queue **/
call: (task, data, options = {}) => {
let { direct, stream } = options;
return new Promise((resolve, reject) => {
let ondata, ondone, onerror = reject;
if (!stream) {
let accum = [];
let simple = options.simple !== false;
ondata = (data) => {
accum.push(data);
};
ondone = () => {
resolve(simple && accum.length === 1 ? accum[0] : accum);
};
} else {
ondata = stream;
ondone = resolve;
}
client.queue(task, data, { direct, ondata, ondone, onerror });
});
},
/** danger: directly add an entry to the work queue **/
queue: (task, data, options = {}) => {
queue.push({task, data, options});
client.kick();
},
/** check work queue and distribute tasks to free workers **/
kick: () => {
if (queue.length === 0) {
return;
}
let rec, worker;
outer: for (let qrec of queue) {
let { options } = qrec;
let { index } = options;
if (index >= 0) {
// worker designated by index
let mod = index % workers.length;
if (workers[mod].run === null) {
worker = workers[mod];
rec = qrec;
break;
}
} else {
// first available worker
for (let i=0; i<workers.length; i++) {
if (workers[i].run === null) {
worker = workers[i];
rec = qrec;
break outer;
}
}
}
}
if (!rec) {
// nothing runnable
return;
}
// remove queue item
let qindex = queue.indexOf(rec);
if (qindex < 0) {
throw "invalid queue record";
}
queue.splice(qindex,1);
let { task, data, options } = rec;
let { direct, ondone, ondata, onerror } = options;
if (ondone || ondata) {
worker.run = { ondone, ondata, onerror };
}
let msg = {
task,
data,
stream: ondata ? true : false
};
try {
worker.postMessage(msg, direct);
} catch (error) {
console.trace('work send error', {data, error});
}
},
// factory can be overridden
spawn: function(url = workurl) {
if (self.createWorker) {
return self.createWorker();
} else {
return new Worker(workurl = url);
}
},
// return number of running requests
running: function() {
return workers.filter(w => w.run).length;
},
stop: function() {
for (let w of workers) {
w.terminate();
}
workers = [];
},
// same as restart
start: function(url = workurl, cc = workcc || 1) {
workcc = cc;
worknum = Math.min(ccmax, workcc);
workurl = url;
client.restart(url, cc);
},
restart: function() {
client.stop();
// send cancel/error notification to requestors
for (let w of workers.filter(w => w.run)) {
if (w.run.ondone) {
w.run.ondone({error: "cancelled operation"});
}
}
for (let i=0; i<worknum; i++) {
let worker = client.spawn(workurl);
worker.run = null;
worker.index = i;
worker.onmessage = function(e) {
let reply = e.data;
// handle special case for task binding
if (reply.bind) {
client.bind(reply.bind);
return;
}
// handle special case when worker ready
if (reply.ready) {
dispatch('ready', reply.ready);
return;
}
// handle special case for broker publish
if (reply.publish) {
broker.publish(reply.publish, reply.message);
return;
}
if (worker.run === null) {
console.log({client_unhandled: e});
throw "message on idle worker";
}
let { done, data, error } = reply;
let { ondone, ondata, onerror } = worker.run;
let handled = false;
if (error) {
onerror(error);
worker.run = null;
handled = true;
client.kick();
}
if (ondata && data) {
ondata(data);
handled = true;
}
if (done) {
ondone();
worker.run = null;
handled = true;
client.kick();
}
if (!handled) {
console.log({client_unhandled: e});
throw "client unhandled reply";
}
};
workers.push(worker);
}
client.kick();
}
};
export { client };

9
src2/moto/license.js Normal file
View file

@ -0,0 +1,9 @@
const is_self = typeof(self) !== 'undefined';
const terms = {
COPYRIGHT: "Copyright (C) Stewart Allen <sa@grid.space> - All Rights Reserved",
LICENSE: "See the license.md file included with the source distribution",
VERSION: (is_self ? self : this).debug_version || "4.1.9"
};
export const license = terms;

642
src2/moto/orbit.js Normal file
View file

@ -0,0 +1,642 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
"use strict";
import { THREE } from '../ext/three.js';
const {
Vector3,
Vector2,
Quaternion,
EventDispatcher,
MOUSE
} = THREE;
/**
* Adapted from THREE.OrbitControls
*/
class Orbit extends EventDispatcher {
constructor(object, domElement, notify, slider) {
super();
this.object = object;
this.domElement = ( domElement !== undefined ) ? domElement : document;
// Set to false to disable this control
this.enabled = true;
// "target" sets the location of focus, where the control orbits around
// and where it pans with respect to.
this.target = new Vector3();
// center is old, deprecated; use "target" instead
this.center = this.target;
// This option actually enables dollying in and out; left as "zoom" for
// backwards compatibility
this.noZoom = false;
this.zoomSpeed = 1.0;
this.reverseZoom = false;
// Limits to how far you can dolly in and out
this.minDistance = 0;
this.maxDistance = Infinity;
// Set to true to disable this control
this.noRotate = false;
this.rotateSpeed = 1.0;
// Set to true to disable this control
this.noPan = false;
this.keyPanSpeed = 7.0; // pixels moved per arrow key push
// How far you can orbit vertically, upper and lower limits.
// Range is 0 to Math.PI radians.
this.minPolarAngle = 0; // radians
this.maxPolarAngle = Math.PI; // radians
// How far you can orbit horizontally, upper and lower limits.
// If set, must be a sub-interval of the interval [ - Math.PI, Math.PI ].
this.minAzimuthAngle = -Infinity; // radians
this.maxAzimuthAngle = Infinity; // radians
// Set to true to disable use of the keys
this.noKeys = false;
// The four arrow keys
this.keys = {
UP: 38,
LEFT: 37,
RIGHT: 39,
BOTTOM: 40
};
// default mouse buttons
this.mouseDefault = {
ORBIT: MOUSE.LEFT,
ZOOM: MOUSE.MIDDLE,
PAN: MOUSE.RIGHT
};
// Onshape
this.mouseOnshape = {
ORBIT: MOUSE.RIGHT,
ZOOM: MOUSE.LEFT,
PAN: MOUSE.MIDDLE
};
this.mouseButtons = this.mouseDefault;
this.setMouse = function(bindings) {
this.mouseButtons = bindings;
};
let scope = this,
domEl = scope.domElement,
EPS = 0.000001,
rotateStart = new Vector2(),
rotateEnd = new Vector2(),
rotateDelta = new Vector2(),
panStart = new Vector2(),
panEnd = new Vector2(),
panDelta = new Vector2(),
panOffset = new Vector3(),
offset = new Vector3(),
dollyStart = new Vector2(),
dollyEnd = new Vector2(),
dollyDelta = new Vector2(),
theta,
thetaDelta = 0,
thetaSet = null,
phi,
phiDelta = 0,
phiSet = null,
scale = 1,
scaleSave = 1,
pan = new Vector3(),
lastPosition = new Vector3(),
lastQuaternion = new Quaternion(),
// so camera.up is the orbit axis
quat = new Quaternion().setFromUnitVectors(object.up, new Vector3(0, 1, 0)),
quatInverse = quat.clone().invert(),
// touch tracking
firstTouch = [],
touchSlide = false,
lastDY = 0,
// events
changeEvent = { type: 'change'},
startEvent = { type: 'start'},
endEvent = { type: 'end'};
let STATE = { NONE: -1, ROTATE: 0, DOLLY: 1, PAN: 2, TOUCH_ROTATE: 3, TOUCH_DOLLY: 4, TOUCH_PAN: 5},
state = STATE.NONE,
MODE = { PERSPECTIVE: 1, ORTHOGRAPHIC: 2, UNKNOWN: 3},
mode = isValue(object.fov) ? MODE.PERSPECTIVE : isValue(object.top) ? MODE.ORTHOGRAPHIC : MODE.UNKNOWN;
// for reset
this.target0 = this.target.clone();
this.position0 = this.object.position.clone();
this.rotateLeft = function (angle) {
thetaDelta -= angle;
};
this.rotateUp = function (angle) {
phiDelta -= angle;
};
// pass in distance in world space to move left
this.panLeft = function (distance) {
let te = this.object.matrix.elements;
// get X column of matrix
panOffset.set(te[ 0 ], te[ 1 ], te[ 2 ]);
panOffset.multiplyScalar(-distance);
pan.add(panOffset);
};
// pass in distance in world space to move up
this.panUp = function (distance) {
let te = this.object.matrix.elements;
// get Y column of matrix
panOffset.set(te[ 4 ], te[ 5 ], te[ 6 ]);
panOffset.multiplyScalar(distance);
pan.add(panOffset);
};
// pass in distance in world space to move backward
this.panBack = function (distance) {
let te = this.object.matrix.elements;
// get Y column of matrix
panOffset.set(te[ 8 ], te[ 9 ], te[ 10 ]);
panOffset.multiplyScalar(distance);
pan.add(panOffset);
};
// pass in x,y of change desired in pixel space,
// right and down are positive
this.pan = function (deltaX, deltaY) {
let element = scope.domElement === document ? scope.domElement.body : scope.domElement;
switch (mode) {
case MODE.PERSPECTIVE:
let position = scope.object.position;
let offset = position.clone().sub(scope.target);
let targetDistance = offset.length();
// half of the fov is center to top of screen
targetDistance *= Math.tan(( scope.object.fov / 2 ) * Math.PI / 180.0);
// we actually don't use screenWidth, since perspective camera is fixed to screen height
scope.panLeft(2 * deltaX * targetDistance / element.clientHeight);
scope.panUp(2 * deltaY * targetDistance / element.clientHeight);
break;
case MODE.ORTHOGRAPHIC:
scope.panLeft(deltaX * (scope.object.right - scope.object.left) / element.clientWidth);
scope.panUp(deltaY * (scope.object.top - scope.object.bottom) / element.clientHeight);
break;
}
};
this.getTarget = function() {
return this.target;
};
this.setTarget = function(t) {
this.target.copy(t);
};
function isValue(v) {
return v !== undefined && v !== null;
}
function firstValue(choices) {
for (let v of choices) {
if (isValue(v)) return v;
}
return null;
}
this.setZoom = function(reverse, speed) {
scope.reverseZoom = reverse;
scope.zoomSpeed = speed || 1.0;
};
this.setPosition = function(set) {
thetaSet = firstValue([set.left, set.theta, thetaSet]);
phiSet = firstValue([set.up, set.phi, phiSet]);
if (set.panX) this.target.x = set.panX;
if (set.panY) this.target.y = set.panY;
if (set.panZ) this.target.z = set.panZ;
if (set.scale) scale = set.scale;
};
this.getPosition = function(scaled) {
let t = this.target,
pos = { left:theta, up:phi, panX:t.x, panY:t.y, panZ:t.z, scale:scaled ? scaleSave : 1 };
return pos;
};
this.dollyIn = function (factor = 1) {
let dollyScale = getZoomScale(factor);
scale = scale * dollyScale;
};
this.dollyOut = function (factor = 1) {
let dollyScale = getZoomScale(factor);
scale = scale / dollyScale;
};
this.update = function () {
let position = this.object.position;
offset.copy(position).sub(this.target);
// rotate offset to "y-axis-is-up" space
offset.applyQuaternion(quat);
// angle from z-axis around y-axis
theta = isValue(thetaSet) ? thetaSet : Math.atan2(offset.x, offset.z);
// angle from y-axis
phi = isValue(phiSet) ? phiSet : Math.atan2(Math.sqrt(offset.x * offset.x + offset.z * offset.z), offset.y);
theta += thetaDelta;
phi += phiDelta;
// restrict theta to be between desired limits
theta = Math.max(this.minAzimuthAngle, Math.min(this.maxAzimuthAngle, theta));
// restrict phi to be between desired limits
phi = Math.max(this.minPolarAngle, Math.min(this.maxPolarAngle, phi));
// restrict phi to be betwee EPS and PI-EPS
phi = Math.max(EPS, Math.min(Math.PI - EPS, phi));
let radius = offset.length() * (mode === MODE.PERSPECTIVE ? scale : 1);
// restrict radius to be between desired limits
radius = Math.max(this.minDistance, Math.min(this.maxDistance, radius));
// move target to panned location
this.target.add(pan);
offset.x = radius * Math.sin(phi) * Math.sin(theta);
offset.y = radius * Math.cos(phi);
offset.z = radius * Math.sin(phi) * Math.cos(theta);
// rotate offset back to "camera-up-vector-is-up" space
offset.applyQuaternion(quatInverse);
position.copy(this.target).add(offset);
this.object.lookAt(this.target);
thetaDelta = 0;
thetaSet = null;
phiDelta = 0;
phiSet = null;
pan.set(0, 0, 0);
scaleSave *= scale;
if (mode === MODE.ORTHOGRAPHIC) {
scope.object.zoom = 1/scale;
scope.object.updateProjectionMatrix();
} else {
scale = 1;
}
// update condition is:
// min(camera displacement, camera rotation in radians)^2 > EPS
// using small-angle approximation cos(x/2) = 1 - x^2 / 8
if (lastPosition.distanceToSquared(this.object.position) > EPS
|| 8 * (1 - lastQuaternion.dot(this.object.quaternion)) > EPS) {
this.dispatchEvent(changeEvent);
lastPosition.copy(this.object.position);
lastQuaternion.copy(this.object.quaternion);
if (notify) notify(position, true);
} else {
if (notify) notify(position, false);
}
};
this.reset = function () {
state = STATE.NONE;
scale = 1;
scaleSave = 1;
this.target.copy(this.target0);
this.object.position.copy(this.position0);
};
this.getPolarAngle = function () {
return phi;
};
this.getAzimuthalAngle = function () {
return theta
};
function getZoomScale(factor = 1) {
return Math.pow(0.95, factor);
}
function onMouseDown(event) {
if (scope.enabled === false) return;
event.preventDefault();
switch (event.button) {
case scope.mouseButtons.ORBIT:
state = event.metaKey ? STATE.PAN : STATE.ROTATE;
break;
case scope.mouseButtons.ZOOM:
state = STATE.DOLLY;
break;
case scope.mouseButtons.PAN:
state = STATE.PAN;
break;
}
switch (state) {
case STATE.ROTATE:
if (scope.noRotate === true) return state = STATE.NONE;
rotateStart.set(event.clientX, event.clientY);
break;
case STATE.DOLLY:
if (scope.noZoom === true) return state = STATE.NONE;
dollyStart.set(event.clientX, event.clientY);
break;
case STATE.PAN:
if (scope.noPan === true) return state = STATE.NONE;
panStart.set(event.clientX, event.clientY);
break;
}
document.addEventListener('mousemove', onMouseMove, false);
document.addEventListener('mouseup', onMouseUp, false);
scope.dispatchEvent(startEvent);
}
function onMouseMove(event) {
if (scope.enabled === false) return;
event.preventDefault();
let element = scope.domElement === document ? scope.domElement.body : scope.domElement;
if (state === STATE.ROTATE) {
if (scope.noRotate === true) return;
rotateEnd.set(event.clientX, event.clientY);
rotateDelta.subVectors(rotateEnd, rotateStart);
// rotating across whole screen goes 360 degrees around
scope.rotateLeft(2 * Math.PI * rotateDelta.x / element.clientWidth * scope.rotateSpeed);
// rotating up and down along whole screen attempts to go 360, but limited to 180
scope.rotateUp(2 * Math.PI * rotateDelta.y / element.clientHeight * scope.rotateSpeed);
rotateStart.copy(rotateEnd);
} else if (state === STATE.DOLLY) {
if (scope.noZoom === true) return;
dollyEnd.set(event.clientX, event.clientY);
dollyDelta.subVectors(dollyEnd, dollyStart);
if (dollyDelta.y > 0) {
scope.dollyIn();
} else {
scope.dollyOut();
}
dollyStart.copy(dollyEnd);
} else if (state === STATE.PAN) {
if (scope.noPan === true) return;
panEnd.set(event.clientX, event.clientY);
panDelta.subVectors(panEnd, panStart);
scope.pan(panDelta.x, panDelta.y);
panStart.copy(panEnd);
}
scope.update();
}
function onMouseUp(event) {
if (scope.enabled === false) return;
document.removeEventListener('mousemove', onMouseMove, false);
document.removeEventListener('mouseup', onMouseUp, false);
scope.dispatchEvent(endEvent);
state = STATE.NONE;
}
function onMouseWheel(event) {
if (scope.enabled === false || scope.noZoom === true) return;
event.preventDefault();
event.stopPropagation();
if (event.shiftKey && slider) {
slider(event.deltaX || event.wheelDelta || event.detail);
return;
}
slider(null);
let delta = -(event.deltaY || event.wheelDelta || event.detail || 0);
if (delta === 0) return;
if (scope.reverseZoom) {
delta = -delta;
}
if (delta > 0) {
scope.dollyOut();
} else if (delta < 0) {
scope.dollyIn();
}
scope.update();
scope.dispatchEvent(startEvent);
scope.dispatchEvent(endEvent);
}
function onKeyDown(event) {
if (scope.enabled === false || scope.noKeys === true || scope.noPan === true) return;
switch (event.keyCode) {
case scope.keys.UP:
scope.pan(0, scope.keyPanSpeed);
scope.update();
break;
case scope.keys.BOTTOM:
scope.pan(0, -scope.keyPanSpeed);
scope.update();
break;
case scope.keys.LEFT:
scope.pan(scope.keyPanSpeed, 0);
scope.update();
break;
case scope.keys.RIGHT:
scope.pan(-scope.keyPanSpeed, 0);
scope.update();
break;
}
}
function touchstart(event) {
if (scope.enabled === false) return;
const touches = [...event.touches];
switch (event.touches.length) {
case 1: // one-fingered touch: rotate
if (scope.noRotate === true) return;
state = STATE.TOUCH_ROTATE;
rotateStart.set(event.touches[ 0 ].pageX, event.touches[ 0 ].pageY);
break;
case 2: // two-fingered touch: dolly
if (scope.noZoom === true) return;
state = STATE.TOUCH_DOLLY;
let dx = event.touches[ 0 ].pageX - event.touches[ 1 ].pageX;
let dy = event.touches[ 0 ].pageY - event.touches[ 1 ].pageY;
let distance = Math.sqrt(dx * dx + dy * dy);
firstTouch = [...event.touches].map(v => { return {x:v.pageX, y:v.pageY} });
touchSlide = false;
lastDY = 0;
dollyStart.set(0, distance);
break;
case 3: // three-fingered touch: pan
case 4: // four-fingered touch: pan
if (scope.noPan === true) return;
state = STATE.TOUCH_PAN;
const px = touches.map(t => t.pageX).reduce((a,b) => a+b) / touches.length;
const py = touches.map(t => t.pageY).reduce((a,b) => a+b) / touches.length;
panStart.set(px, py);
break;
default:
state = STATE.NONE;
}
scope.dispatchEvent(startEvent);
}
function touchmove(event) {
if (scope.enabled === false) return;
event.preventDefault();
event.stopPropagation();
const element = scope.domElement === document ? scope.domElement.body : scope.domElement;
switch (event.touches.length) {
case 1: // one-fingered touch: rotate
if (scope.noRotate === true) return;
if (state !== STATE.TOUCH_ROTATE) return;
rotateEnd.set(event.touches[ 0 ].pageX, event.touches[ 0 ].pageY);
rotateDelta.subVectors(rotateEnd, rotateStart);
// rotating across whole screen goes 360 degrees around
scope.rotateLeft(2 * Math.PI * rotateDelta.x / element.clientWidth * scope.rotateSpeed);
// rotating up and down along whole screen attempts to go 360, but limited to 180
scope.rotateUp(2 * Math.PI * rotateDelta.y / element.clientHeight * scope.rotateSpeed);
rotateStart.copy(rotateEnd);
scope.update();
break;
case 2: // two-fingered touch: dolly
if (scope.noZoom === true) return;
if (state !== STATE.TOUCH_DOLLY) return;
let dx = event.touches[ 0 ].pageX - event.touches[ 1 ].pageX;
let dy = event.touches[ 0 ].pageY - event.touches[ 1 ].pageY;
let distance = Math.sqrt(dx * dx + dy * dy);
let touches = [...event.touches].map(v => { return {x:v.pageX, y:v.pageY} });
let ddx = ( (touches[0].x - firstTouch[0].x) + (touches[1].x - firstTouch[1].x) ) / 2;
let ddy = ( (touches[0].y - firstTouch[0].y) + (touches[1].y - firstTouch[1].y) ) / 2;
if (scope.reverseZoom) distance = -distance;
dollyEnd.set(0, distance);
dollyDelta.subVectors(dollyEnd, dollyStart);
if (touchSlide || (Math.abs(dy/dx) < 1 && slider && dx < 200)) {
touchSlide = true;
slider(lastDY ? (lastDY - ddy) : ddy);
lastDY = ddy;
} else {
if (dollyDelta.y > 0) {
scope.dollyOut(0.5);
} else if (dollyDelta.y < 0) {
scope.dollyIn(0.5);
}
}
dollyStart.copy(dollyEnd);
scope.update();
break;
case 3: // three-fingered touch: pan
case 4: // four-fingered touch: pan
if (scope.noPan === true) return;
if (state !== STATE.TOUCH_PAN) return;
const tlist = [...event.touches];
const px = tlist.map(t => t.pageX).reduce((a,b) => a+b) / tlist.length;
const py = tlist.map(t => t.pageY).reduce((a,b) => a+b) / tlist.length;
panEnd.set(px, py);
panDelta.subVectors(panEnd, panStart);
scope.pan(panDelta.x, panDelta.y);
panStart.copy(panEnd);
scope.update();
break;
default:
state = STATE.NONE;
}
}
function touchend(/* event */) {
if (scope.enabled === false) return;
scope.dispatchEvent(endEvent);
state = STATE.NONE;
}
this.onMouseUp = onMouseUp;
domEl.addEventListener('contextmenu', function (event) { event.preventDefault() }, false);
domEl.addEventListener('mousedown', onMouseDown, false);
domEl.addEventListener('mousewheel', onMouseWheel, false);
domEl.addEventListener('DOMMouseScroll', onMouseWheel, false); // firefox
domEl.addEventListener('touchstart', touchstart, false);
domEl.addEventListener('touchend', touchend, false);
domEl.addEventListener('touchmove', touchmove, false);
window.addEventListener('keydown', onKeyDown, false);
}
}
export { Orbit };

159
src2/moto/service.js Normal file
View file

@ -0,0 +1,159 @@
// code for a ServiceWorker that provides support for running cached/offline
import { license } from './license.js';
const version = license.version;
const origin = self.location.origin;
const stats = {
hit: 0,
miss: 0,
fetch: 0,
cache: 0,
bypass: 0,
preload: 0,
timer: undefined
};
function debug() {
console.log(`[${version}]`, ...arguments);
}
function update() {
clearTimeout(stats.timer);
stats.timer = setTimeout(report, 1000);
}
function report() {
console.log('sw cache', Object.assign({}, stats));
stats.hit = stats.miss = stats.fetch = stats.cache = stats.bypass = stats.preload = 0;
}
debug('sw entry point');
const addResourcesToCache = async (resources) => {
// debug('cache resources', resources);
const cache = await caches.open('v'+version);
await cache.addAll(resources);
};
const putInCache = async (request, response) => {
const cache = await caches.open('v'+version);
// debug('cache', request.url);
update(stats.cache++);
await cache.put(request, response);
};
const cacheFirst = async ({
request,
preloadResponsePromise,
fallbackUrl
}) => {
// must await this promise when present to avoid errors
const preloadResponse = preloadResponsePromise ? await preloadResponsePromise : undefined;
// try to get the resource from the cache
const cache = await caches.open('v'+version);
const responseFromCache = await cache.match(request);
if (responseFromCache) {
// debug('cached', request.url);
update(stats.hit++);
return responseFromCache;
} else {
// debug('miss', request.url);
update(stats.miss++);
}
// try to use the preloaded response, if it's there
if (preloadResponse) {
update(stats.preload++);
debug('using preload response', preloadResponse);
putInCache(request, preloadResponse.clone());
return preloadResponse;
}
// try to get the resource from the network
try {
// debug('fetch', request.url);
update(fetch.fetch++);
const responseFromNetwork = await fetch(request);
// response may be used only once
// we need to save clone to put one copy in cache and serve second one
putInCache(request, responseFromNetwork.clone());
return responseFromNetwork;
} catch (error) {
debug({fallback_error: error});
const cache = await caches.open('v'+version);
const fallbackResponse = await cache.match(fallbackUrl);
if (fallbackResponse) {
return fallbackResponse;
}
// when even the fallback response is not available,
// there is nothing we can do, but we must always
// return a Response object
return new Response('Network error happened', {
headers: { 'Content-Type': 'text/plain' },
status: 408
});
}
};
const enableNavigationPreload = async () => {
if (self.registration.navigationPreload) {
// Enable navigation preloads!
// debug('sw preload');
try {
await self.registration.navigationPreload.enable();
} catch (e) {
debug('sw preload fail', e);
}
}
};
self.addEventListener('activate', (event) => {
const vkey = 'v'+version;
// debug('sw activate');
// event.waitUntil(enableNavigationPreload());
// cleanup old caches
event.waitUntil(
caches.keys()
.then( keylist => keylist
.filter(key => key != vkey)
.map(key => { debug('sw delete cache', key); return caches.delete(key); }) )
.then( deletes => Promise.all(deletes) )
);
event.waitUntil(new Promise((resolve, reject) => {
setTimeout(resolve, 5000);
}));
debug('sw activated');
});
self.addEventListener('install', (event) => {
// debug('sw install');
// event.waitUntil( addResourcesToCache([ ]) );
self.skipWaiting();
debug('sw installed');
});
self.addEventListener('fetch', (event) => {
const { request,preloadResponse } = event;
if (request.url.indexOf(origin) !== 0 || request.method !== 'GET') {
debug('no-cache', request.url);
stats.bypass++;
event.respondWith(fetch(request));
return;
}
event.respondWith(
cacheFirst({
request: request,
preloadResponsePromise: preloadResponse,
fallbackUrl: '//static.grid.space/img/logo_gs.png',
})
);
});
self.addEventListener('message', (event) => {
// debug({ onmessage: event, clients });
});

1513
src2/moto/space.js Normal file

File diff suppressed because it is too large Load diff

168
src2/moto/webui.js Normal file
View file

@ -0,0 +1,168 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
let nextid = 1;
function build(data, context) {
if (!data) {
return [];
}
if (Array.isArray(data)) {
let html = [];
for (let d of data) {
html.appendAll(build(d, context));
}
return html;
}
let { type, attr, innr, raw } = data;
if (raw) {
return [ raw ];
}
// auto text content
if (typeof attr === 'string') {
attr = { _: attr };
}
let elid;
let html = [];
let text
html.push(`<${type}`);
let func = {};
for (let [key, val] of Object.entries(attr || {})) {
if (val === undefined) {
continue;
}
let tov = typeof val;
if (key === '_') {
text = val;
} else if (key.startsWith('_')) {
if (val) html.push(` ${key.substring(1)}`);
} else if (key === 'id') {
elid = val ? (tov === 'object' ? val.join('_') : val) : undefined;
} else if (tov === 'function') {
func[key] = val;
elid = elid || `_${nextid++}`;
} else if (tov === 'object') {
if (val.length)
html.push(` ${key}="${val.filter(v => v !== undefined).join(' ')}"`);
} else if (tov !== 'undefined') {
html.push(` ${key}="${val}"`);
}
}
if (elid) {
html.push(` id="${elid}"`);
context.push({elid, func});
}
html.push('>');
if (innr) {
let snips = [];
if (innr.length) {
for (let i of innr) {
snips.push(build(i, context));
}
} else {
snips.push(build(innr, context));
}
html.appendAll(snips.flat());
}
if (text !== undefined) {
html.push(text);
}
html.push(`</${type}>`);
return html;
}
// core html builder funtions
const h = {
bind: (el, data, opt = {}) => {
let ctx = [];
let html = build(data, ctx).join('');
if (opt.after || opt.before) {
let tmpl = document.createElement('template');
tmpl.innerHTML = html;
opt.before && el.before(tmpl.content);
opt.after && el.after(tmpl.content);
} else if (opt.append) {
el.innerHTML += html;
} else {
el.innerHTML = html;
}
let map = {};
for (let bind of ctx) {
let { elid, func } = bind;
let et = $(elid);
map[elid] = et;
for (let [name, fn] of Object.entries(func)) {
et[name] = fn;
}
}
return map;
},
el: (type, attr, innr) => {
if (Array.isArray(attr)) {
innr = attr;
attr = {};
}
return { type, attr, innr };
},
raw: ( text ) => {
return { raw: text }
},
build: (data) => {
return build(data, []).join('');
}
};
// window ui helpers
const $ = (id) => {
return document.getElementById(id);
};
const $d = (id, v) => {
$(id).style.display = v;
};
const $h = (id, h) => {
$(id).innerHTML = h;
};
const $c = (id, add, del) => {
if (add) $(id).classList.add(add);
if (del) $(id).classList.remove(del);
};
const $C = (clazz, add, del) => {
let el = [...document.getElementsByClassName(clazz)];
for (let e of el) {
if (add) e.classList.add(add);
if (del) e.classList.remove(del);
}
return el;
};
const estop = (evt) => {
evt.stopPropagation();
evt.preventDefault();
};
// add common element types
[
"a", "i", "hr", "div", "pre", "code", "span", "label", "input",
"button", "svg", "textarea", "select", "option", "img", "canvas"
].forEach(type => {
h[type] = (attr, innr) => {
return h.el(type, attr, innr);
}
});
export {
$,
$d,
$h,
$c,
$C,
h,
estop
};

98
src2/moto/worker.js Normal file
View file

@ -0,0 +1,98 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
let endpoints = {};
// code is running in the worker / server context
const dispatch = {
// bind an endpoint name to a function
bind(name, fn) {
endpoints[name] = fn;
dispatch.send({bind: name});
},
// bind all functions in an object
bindObject(object, root, recurse) {
recurse = recurse || root === undefined;
for (let [key, fn] of Object.entries(object)) {
key = root ? `${root}_${key}` : key;
if (typeof fn === 'function') {
dispatch.bind(key, fn);
} else if (recurse) {
dispatch.bindObject(fn, key, recurse);
}
}
},
// allow workers to publish messages on the client side
publish(topic, message) {
dispatch.send({publish: topic, message});
},
ready() {
dispatch.send({ready: true});
},
send(msg) {
self.postMessage(msg);
},
onmessage(e) {
let msg = e.data,
run = endpoints[msg.task],
async = false,
done = false,
send = {
// puts function in async mode
// prevents auto send.done() on function return
async() {
async = true;
},
data(data, direct) {
if (canSend(data)) {
dispatch.send({ done: false, data: data }, direct);
}
},
done(data, direct) {
if (canSend(data)) {
dispatch.send({ done: true, data: data }, direct);
done = true;
}
},
error(data) {
if (canSend(data)) {
done = true;
dispatch.send({ error: data });
}
}
},
canSend = (data) => {
if (done) {
console.log("unexpected reply dropped", {data, msg});
}
return !done;
};
if (run) {
try {
let data = run(msg.data, send);
if (!done && (data || async === false)) {
send.done(data);
}
} catch (error) {
if (error.stack) {
console.trace(error.stack);
}
send.error(error.toString());
// dispatch.send({ data: {error: error.toString()}, done: true });
}
} else {
console.log({worker_unhandled: e});
send.error(`no registered endpoint: ${msg.task}`);
}
}
};
// attach onmessage hanler
self.onmessage = dispatch.onmessage;
export const worker = dispatch;

4629
src2/wasm/clipper.cpp Normal file

File diff suppressed because it is too large Load diff

404
src2/wasm/clipper.hpp Normal file
View file

@ -0,0 +1,404 @@
/*******************************************************************************
* *
* Author : Angus Johnson *
* Version : 6.4.2 *
* Date : 27 February 2017 *
* Website : http://www.angusj.com *
* Copyright : Angus Johnson 2010-2017 *
* *
* License: *
* Use, modification & distribution is subject to Boost Software License Ver 1. *
* http://www.boost.org/LICENSE_1_0.txt *
* *
* Attributions: *
* The code in this library is an extension of Bala Vatti's clipping algorithm: *
* "A generic solution to polygon clipping" *
* Communications of the ACM, Vol 35, Issue 7 (July 1992) pp 56-63. *
* http://portal.acm.org/citation.cfm?id=129906 *
* *
* Computer graphics and geometric modeling: implementation and algorithms *
* By Max K. Agoston *
* Springer; 1 edition (January 4, 2005) *
* http://books.google.com/books?q=vatti+clipping+agoston *
* *
* See also: *
* "Polygon Offsetting by Computing Winding Numbers" *
* Paper no. DETC2005-85513 pp. 565-575 *
* ASME 2005 International Design Engineering Technical Conferences *
* and Computers and Information in Engineering Conference (IDETC/CIE2005) *
* September 24-28, 2005 , Long Beach, California, USA *
* http://www.me.berkeley.edu/~mcmains/pubs/DAC05OffsetPolygon.pdf *
* *
*******************************************************************************/
#ifndef clipper_hpp
#define clipper_hpp
#define CLIPPER_VERSION "6.4.2"
//use_int32: When enabled 32bit ints are used instead of 64bit ints. This
//improve performance but coordinate values are limited to the range +/- 46340
//#define use_int32
//use_xyz: adds a Z member to IntPoint. Adds a minor cost to perfomance.
//#define use_xyz
//use_lines: Enables line clipping. Adds a very minor cost to performance.
#define use_lines
//use_deprecated: Enables temporary support for the obsolete functions
//#define use_deprecated
#include <vector>
#include <list>
#include <set>
#include <stdexcept>
#include <cstring>
#include <cstdlib>
#include <ostream>
#include <functional>
#include <queue>
namespace ClipperLib {
enum ClipType { ctIntersection, ctUnion, ctDifference, ctXor };
enum PolyType { ptSubject, ptClip };
//By far the most widely used winding rules for polygon filling are
//EvenOdd & NonZero (GDI, GDI+, XLib, OpenGL, Cairo, AGG, Quartz, SVG, Gr32)
//Others rules include Positive, Negative and ABS_GTR_EQ_TWO (only in OpenGL)
//see http://glprogramming.com/red/chapter11.html
enum PolyFillType { pftEvenOdd, pftNonZero, pftPositive, pftNegative };
#ifdef use_int32
typedef int cInt;
static cInt const loRange = 0x7FFF;
static cInt const hiRange = 0x7FFF;
#else
typedef signed long long cInt;
static cInt const loRange = 0x3FFFFFFF;
static cInt const hiRange = 0x3FFFFFFFFFFFFFFFLL;
typedef signed long long long64; //used by Int128 class
typedef unsigned long long ulong64;
#endif
struct IntPoint {
cInt X;
cInt Y;
#ifdef use_xyz
cInt Z;
IntPoint(cInt x = 0, cInt y = 0, cInt z = 0): X(x), Y(y), Z(z) {};
#else
IntPoint(cInt x = 0, cInt y = 0): X(x), Y(y) {};
#endif
friend inline bool operator== (const IntPoint& a, const IntPoint& b)
{
return a.X == b.X && a.Y == b.Y;
}
friend inline bool operator!= (const IntPoint& a, const IntPoint& b)
{
return a.X != b.X || a.Y != b.Y;
}
};
//------------------------------------------------------------------------------
typedef std::vector< IntPoint > Path;
typedef std::vector< Path > Paths;
inline Path& operator <<(Path& poly, const IntPoint& p) {poly.push_back(p); return poly;}
inline Paths& operator <<(Paths& polys, const Path& p) {polys.push_back(p); return polys;}
std::ostream& operator <<(std::ostream &s, const IntPoint &p);
std::ostream& operator <<(std::ostream &s, const Path &p);
std::ostream& operator <<(std::ostream &s, const Paths &p);
struct DoublePoint
{
double X;
double Y;
DoublePoint(double x = 0, double y = 0) : X(x), Y(y) {}
DoublePoint(IntPoint ip) : X((double)ip.X), Y((double)ip.Y) {}
};
//------------------------------------------------------------------------------
#ifdef use_xyz
typedef void (*ZFillCallback)(IntPoint& e1bot, IntPoint& e1top, IntPoint& e2bot, IntPoint& e2top, IntPoint& pt);
#endif
enum InitOptions {ioReverseSolution = 1, ioStrictlySimple = 2, ioPreserveCollinear = 4};
enum JoinType {jtSquare, jtRound, jtMiter};
enum EndType {etClosedPolygon, etClosedLine, etOpenButt, etOpenSquare, etOpenRound};
class PolyNode;
typedef std::vector< PolyNode* > PolyNodes;
class PolyNode
{
public:
PolyNode();
virtual ~PolyNode(){};
Path Contour;
PolyNodes Childs;
PolyNode* Parent;
PolyNode* GetNext() const;
bool IsHole() const;
bool IsOpen() const;
int ChildCount() const;
private:
//PolyNode& operator =(PolyNode& other);
unsigned Index; //node index in Parent.Childs
bool m_IsOpen;
JoinType m_jointype;
EndType m_endtype;
PolyNode* GetNextSiblingUp() const;
void AddChild(PolyNode& child);
friend class Clipper; //to access Index
friend class ClipperOffset;
};
class PolyTree: public PolyNode
{
public:
~PolyTree(){ Clear(); };
PolyNode* GetFirst() const;
void Clear();
int Total() const;
private:
//PolyTree& operator =(PolyTree& other);
PolyNodes AllNodes;
friend class Clipper; //to access AllNodes
};
bool Orientation(const Path &poly);
double Area(const Path &poly);
int PointInPolygon(const IntPoint &pt, const Path &path);
void SimplifyPolygon(const Path &in_poly, Paths &out_polys, PolyFillType fillType = pftEvenOdd);
void SimplifyPolygons(const Paths &in_polys, Paths &out_polys, PolyFillType fillType = pftEvenOdd);
void SimplifyPolygons(Paths &polys, PolyFillType fillType = pftEvenOdd);
void CleanPolygon(const Path& in_poly, Path& out_poly, double distance = 1.415);
void CleanPolygon(Path& poly, double distance = 1.415);
void CleanPolygons(const Paths& in_polys, Paths& out_polys, double distance = 1.415);
void CleanPolygons(Paths& polys, double distance = 1.415);
void MinkowskiSum(const Path& pattern, const Path& path, Paths& solution, bool pathIsClosed);
void MinkowskiSum(const Path& pattern, const Paths& paths, Paths& solution, bool pathIsClosed);
void MinkowskiDiff(const Path& poly1, const Path& poly2, Paths& solution);
void PolyTreeToPaths(const PolyTree& polytree, Paths& paths);
void ClosedPathsFromPolyTree(const PolyTree& polytree, Paths& paths);
void OpenPathsFromPolyTree(PolyTree& polytree, Paths& paths);
void ReversePath(Path& p);
void ReversePaths(Paths& p);
struct IntRect { cInt left; cInt top; cInt right; cInt bottom; };
//enums that are used internally ...
enum EdgeSide { esLeft = 1, esRight = 2};
//forward declarations (for stuff used internally) ...
struct TEdge;
struct IntersectNode;
struct LocalMinimum;
struct OutPt;
struct OutRec;
struct Join;
typedef std::vector < OutRec* > PolyOutList;
typedef std::vector < TEdge* > EdgeList;
typedef std::vector < Join* > JoinList;
typedef std::vector < IntersectNode* > IntersectList;
//------------------------------------------------------------------------------
//ClipperBase is the ancestor to the Clipper class. It should not be
//instantiated directly. This class simply abstracts the conversion of sets of
//polygon coordinates into edge objects that are stored in a LocalMinima list.
class ClipperBase
{
public:
ClipperBase();
virtual ~ClipperBase();
virtual bool AddPath(const Path &pg, PolyType PolyTyp, bool Closed);
bool AddPaths(const Paths &ppg, PolyType PolyTyp, bool Closed);
virtual void Clear();
IntRect GetBounds();
bool PreserveCollinear() {return m_PreserveCollinear;};
void PreserveCollinear(bool value) {m_PreserveCollinear = value;};
protected:
void DisposeLocalMinimaList();
TEdge* AddBoundsToLML(TEdge *e, bool IsClosed);
virtual void Reset();
TEdge* ProcessBound(TEdge* E, bool IsClockwise);
void InsertScanbeam(const cInt Y);
bool PopScanbeam(cInt &Y);
bool LocalMinimaPending();
bool PopLocalMinima(cInt Y, const LocalMinimum *&locMin);
OutRec* CreateOutRec();
void DisposeAllOutRecs();
void DisposeOutRec(PolyOutList::size_type index);
void SwapPositionsInAEL(TEdge *edge1, TEdge *edge2);
void DeleteFromAEL(TEdge *e);
void UpdateEdgeIntoAEL(TEdge *&e);
typedef std::vector<LocalMinimum> MinimaList;
MinimaList::iterator m_CurrentLM;
MinimaList m_MinimaList;
bool m_UseFullRange;
EdgeList m_edges;
bool m_PreserveCollinear;
bool m_HasOpenPaths;
PolyOutList m_PolyOuts;
TEdge *m_ActiveEdges;
typedef std::priority_queue<cInt> ScanbeamList;
ScanbeamList m_Scanbeam;
};
//------------------------------------------------------------------------------
class Clipper : public virtual ClipperBase
{
public:
Clipper(int initOptions = 0);
bool Execute(ClipType clipType,
Paths &solution,
PolyFillType fillType = pftEvenOdd);
bool Execute(ClipType clipType,
Paths &solution,
PolyFillType subjFillType,
PolyFillType clipFillType);
bool Execute(ClipType clipType,
PolyTree &polytree,
PolyFillType fillType = pftEvenOdd);
bool Execute(ClipType clipType,
PolyTree &polytree,
PolyFillType subjFillType,
PolyFillType clipFillType);
bool ReverseSolution() { return m_ReverseOutput; };
void ReverseSolution(bool value) {m_ReverseOutput = value;};
bool StrictlySimple() {return m_StrictSimple;};
void StrictlySimple(bool value) {m_StrictSimple = value;};
//set the callback function for z value filling on intersections (otherwise Z is 0)
#ifdef use_xyz
void ZFillFunction(ZFillCallback zFillFunc);
#endif
protected:
virtual bool ExecuteInternal();
private:
JoinList m_Joins;
JoinList m_GhostJoins;
IntersectList m_IntersectList;
ClipType m_ClipType;
typedef std::list<cInt> MaximaList;
MaximaList m_Maxima;
TEdge *m_SortedEdges;
bool m_ExecuteLocked;
PolyFillType m_ClipFillType;
PolyFillType m_SubjFillType;
bool m_ReverseOutput;
bool m_UsingPolyTree;
bool m_StrictSimple;
#ifdef use_xyz
ZFillCallback m_ZFill; //custom callback
#endif
void SetWindingCount(TEdge& edge);
bool IsEvenOddFillType(const TEdge& edge) const;
bool IsEvenOddAltFillType(const TEdge& edge) const;
void InsertLocalMinimaIntoAEL(const cInt botY);
void InsertEdgeIntoAEL(TEdge *edge, TEdge* startEdge);
void AddEdgeToSEL(TEdge *edge);
bool PopEdgeFromSEL(TEdge *&edge);
void CopyAELToSEL();
void DeleteFromSEL(TEdge *e);
void SwapPositionsInSEL(TEdge *edge1, TEdge *edge2);
bool IsContributing(const TEdge& edge) const;
bool IsTopHorz(const cInt XPos);
void DoMaxima(TEdge *e);
void ProcessHorizontals();
void ProcessHorizontal(TEdge *horzEdge);
void AddLocalMaxPoly(TEdge *e1, TEdge *e2, const IntPoint &pt);
OutPt* AddLocalMinPoly(TEdge *e1, TEdge *e2, const IntPoint &pt);
OutRec* GetOutRec(int idx);
void AppendPolygon(TEdge *e1, TEdge *e2);
void IntersectEdges(TEdge *e1, TEdge *e2, IntPoint &pt);
OutPt* AddOutPt(TEdge *e, const IntPoint &pt);
OutPt* GetLastOutPt(TEdge *e);
bool ProcessIntersections(const cInt topY);
void BuildIntersectList(const cInt topY);
void ProcessIntersectList();
void ProcessEdgesAtTopOfScanbeam(const cInt topY);
void BuildResult(Paths& polys);
void BuildResult2(PolyTree& polytree);
void SetHoleState(TEdge *e, OutRec *outrec);
void DisposeIntersectNodes();
bool FixupIntersectionOrder();
void FixupOutPolygon(OutRec &outrec);
void FixupOutPolyline(OutRec &outrec);
bool IsHole(TEdge *e);
bool FindOwnerFromSplitRecs(OutRec &outRec, OutRec *&currOrfl);
void FixHoleLinkage(OutRec &outrec);
void AddJoin(OutPt *op1, OutPt *op2, const IntPoint offPt);
void ClearJoins();
void ClearGhostJoins();
void AddGhostJoin(OutPt *op, const IntPoint offPt);
bool JoinPoints(Join *j, OutRec* outRec1, OutRec* outRec2);
void JoinCommonEdges();
void DoSimplePolygons();
void FixupFirstLefts1(OutRec* OldOutRec, OutRec* NewOutRec);
void FixupFirstLefts2(OutRec* InnerOutRec, OutRec* OuterOutRec);
void FixupFirstLefts3(OutRec* OldOutRec, OutRec* NewOutRec);
#ifdef use_xyz
void SetZ(IntPoint& pt, TEdge& e1, TEdge& e2);
#endif
};
//------------------------------------------------------------------------------
class ClipperOffset
{
public:
ClipperOffset(double miterLimit = 2.0, double roundPrecision = 0.25);
~ClipperOffset();
void AddPath(const Path& path, JoinType joinType, EndType endType);
void AddPaths(const Paths& paths, JoinType joinType, EndType endType);
void Execute(Paths& solution, double delta);
void Execute(PolyTree& solution, double delta);
void Clear();
double MiterLimit;
double ArcTolerance;
private:
Paths m_destPolys;
Path m_srcPoly;
Path m_destPoly;
std::vector<DoublePoint> m_normals;
double m_delta, m_sinA, m_sin, m_cos;
double m_miterLim, m_StepsPerRad;
IntPoint m_lowest;
PolyNode m_polyNodes;
void FixOrientations();
void DoOffset(double delta);
void OffsetPoint(int j, int& k, JoinType jointype);
void DoSquare(int j, int k);
void DoMiter(int j, int k, double r);
void DoRound(int j, int k);
};
//------------------------------------------------------------------------------
class clipperException : public std::exception
{
public:
clipperException(const char* description): m_descr(description) {}
virtual ~clipperException() throw() {}
virtual const char* what() const throw() {return m_descr.c_str();}
private:
std::string m_descr;
};
//------------------------------------------------------------------------------
} //ClipperLib namespace
#endif //clipper_hpp

24
src2/wasm/kiri-ani.c Normal file
View file

@ -0,0 +1,24 @@
#include <emscripten.h>
#include <string.h>
typedef unsigned char Uint8;
typedef unsigned short Uint16;
typedef unsigned int Uint32;
extern void reportf(float a, float b);
extern void reporti(int a, int b);
/**
* m = memory base pointer
* im = input memory location (mesh)
* it = input memory location (tool)
* ol = output memory location (mesh-updates)
* returns length of update at output memory location
*/
EMSCRIPTEN_KEEPALIVE
Uint32 updateMesh(unsigned char *m, Uint32 im, Uint32 it, Uint32 ol) {
int rv = m[0] + m[100] + m[200];
m[0] = 123;
reporti(m[100],m[200]);
return rv;
}

BIN
src2/wasm/kiri-ani.wasm Executable file

Binary file not shown.

189
src2/wasm/kiri-geo.cpp Normal file
View file

@ -0,0 +1,189 @@
//#define use_int32
#include <emscripten.h>
#include "clipper.hpp"
typedef unsigned char Uint8;
typedef unsigned short Uint16;
typedef unsigned int Uint32;
typedef int int32;
using namespace ClipperLib;
Uint8 *mem = 0;
extern "C" {
extern void debug_string(Uint32 len, char *str);
}
struct length16 {
Uint16 length;
};
struct point32 {
int32 x;
int32 y;
};
__attribute__ ((export_name("mem_get")))
Uint32 mem_get(Uint32 size) {
return (Uint32)malloc(size);
}
__attribute__ ((export_name("mem_clr")))
void mem_clr(Uint32 loc) {
free((void *)loc);
}
void send_string(const char *format, ...) {
char buffer[100];
va_list args;
va_start(args, format);
Uint32 len = vsprintf(buffer, format, args);
va_end(args);
debug_string(len, buffer);
}
Uint32 readPoly(Path &path, Uint32 pos) {
struct length16 *ls = (struct length16 *)(mem + pos);
Uint16 points = ls->length;
pos += 2;
while (points > 0) {
struct point32 *ip = (struct point32 *)(mem + pos);
pos += 8;
path << IntPoint(ip->x, ip->y);
points--;
}
return pos;
}
Uint32 readPolys(Paths &paths, Uint32 pos, Uint32 count) {
Uint32 poly = 0;
while (count > 0) {
pos = readPoly(paths[poly++], pos);
count--;
}
return pos;
}
Uint32 writePolys(Paths &outs, Uint32 pos) {
for (Path po : outs) {
struct length16 *ls = (struct length16 *)(mem + pos);
ls->length = po.size();
pos += 2;
for (IntPoint pt : po) {
struct point32 *ip = (struct point32 *)(mem + pos);
ip->x = (int)pt.X;
ip->y = (int)pt.Y;
pos += 8;
}
}
// null terminate
struct length16 *ls = (struct length16 *)(mem + pos);
ls->length = 0;
return pos + 2;
}
__attribute__ ((export_name("poly_offset")))
Uint32 poly_offset(Uint32 memat, Uint32 polys, float offset, float clean, Uint8 simple) {
Paths ins(polys);
Paths outs;
Uint32 pos = memat;
Uint16 poly = 0;
pos = readPolys(ins, pos, polys);
if (clean > 0) {
Paths cleans;
CleanPolygons(ins, cleans, clean);
ins = cleans;
}
if (simple > 0) {
Paths simples;
SimplifyPolygons(ins, simples);
ins = simples;
}
ClipperOffset co;
co.AddPaths(ins, jtMiter, etClosedPolygon);
co.Execute(outs, offset);
Uint32 resat = pos;
pos = writePolys(outs, pos);
co.Clear();
return resat;
}
__attribute__ ((export_name("poly_union")))
Uint32 poly_union(Uint32 memat, Uint32 polys, float offset) {
Paths ins(polys);
Paths outs;
Uint32 pos = memat;
Uint16 poly = 0;
pos = readPolys(ins, pos, polys);
Clipper clip;
clip.AddPaths(ins, ptSubject, true);
clip.Execute(ctUnion, outs);
Uint32 resat = pos;
pos = writePolys(outs, pos);
clip.Clear();
return resat;
}
__attribute__ ((export_name("poly_diff")))
Uint32 poly_diff(Uint32 memat, Uint32 polysA, Uint32 polysB, Uint8 AB, Uint8 BA, float clean) {
Paths inA(polysA);
Paths inB(polysB);
Uint32 pos = memat;
pos = readPolys(inA, pos, polysA);
pos = readPolys(inB, pos, polysB);
Uint32 resat = pos;
if (AB > 0) {
Paths outs;
Clipper clip;
clip.AddPaths(inA, ptSubject, true);
clip.AddPaths(inB, ptClip, true);
clip.Execute(ctDifference, outs, pftEvenOdd, pftEvenOdd);
if (clean > 0) {
// CleanPolygons(outs, clean);
for (Path po : outs) {
CleanPolygon(po, clean);
}
}
pos = writePolys(outs, pos);
clip.Clear();
}
if (BA > 0) {
Paths outs;
Clipper clip;
clip.AddPaths(inB, ptSubject, true);
clip.AddPaths(inA, ptClip, true);
clip.Execute(ctDifference, outs, pftEvenOdd, pftEvenOdd);
if (clean > 0) {
// CleanPolygons(outs, clean);
for (Path po : outs) {
CleanPolygon(po, clean);
}
}
pos = writePolys(outs, pos);
clip.Clear();
}
return resat;
}

BIN
src2/wasm/kiri-geo.wasm Executable file

Binary file not shown.

217
src2/wasm/kiri-sla.c Normal file
View file

@ -0,0 +1,217 @@
#include <emscripten.h>
#include <string.h>
typedef unsigned char Uint8;
typedef unsigned short Uint16;
typedef unsigned int Uint32;
struct info {
Uint16 width; // image width
Uint16 height; // image height
Uint16 polys; // total number of polygons to render
};
struct poly {
Uint16 length; // offset to end of record in bytes
Uint16 inners; // number of inner polygons
Uint16 points; // number of points in polygon
Uint16 minx;
Uint16 maxx;
Uint16 miny;
Uint16 maxy;
};
struct point {
float x;
float y;
};
const Uint8 ph_size = sizeof(struct poly);
const Uint8 pt_size = sizeof(struct point);
extern void reportf(float a, float b);
extern void reporti(int a, int b);
Uint32 readoff; // read position offset
Uint8 check_cross(Uint32 x, Uint32 y, struct point *p1, struct point *p2) {
return (
((p1->y >= y) != (p2->y >= y)) &&
(x < (p2->x - p1->x) * (y - p1->y) / (p2->y - p1->y) + p1->x)
) ? 1 : 0;
}
Uint8 is_inside_poly(Uint32 x, Uint32 y, unsigned char *m, Uint32 o) {
struct poly *poly = (struct poly *)(m + readoff);
Uint32 nextpoly = readoff + poly->length;
Uint8 side = 0; // 0=outside, 1=inside
// skip post header
readoff += ph_size;
// save pointer to first point or last comparison
struct point *pf = (struct point *)(m + readoff);
struct point *p1 = pf;
struct point *p2;
// skip past first point
readoff += pt_size;
// read and check point pairs updating `side`
for (Uint16 i=1; i<poly->points; i++) {
p2 = (struct point *)(m + readoff);
// increment read pointe
readoff += pt_size;
// check current/previous point crossing
if (check_cross(x, y, p1, p2)) {
side = 1 - side;
}
p1 = p2;
}
// check last/first point crossing
if (check_cross(x, y, p1, pf)) {
side = 1 - side;
}
return side;
}
void rasterize_poly(unsigned char *m, Uint32 o, Uint32 height) {
struct poly *poly = (struct poly *)(m + readoff);
Uint32 nextpoly = readoff + poly->length;
// skip post header
readoff += ph_size;
// save pointer to first point or last comparison
struct point *pf = (struct point *)(m + readoff);
struct point *p1 = pf;
struct point *p2;
// skip past first point
readoff += pt_size;
// point data starts here
Uint32 pstart = readoff;
// scan bounding area of polygon
for (Uint32 x=poly->minx; x<poly->maxx; x++) {
for (Uint32 y=poly->miny; y<poly->maxy; y++) {
Uint8 side = 0; // 0=outside, 1=inside
// re-start at point data offset for each x,y
readoff = pstart;
p1 = pf;
// read and check point pairs updating `side`
for (Uint16 i=1; i<poly->points; i++) {
p2 = (struct point *)(m + readoff);
// increment read pointe
readoff += pt_size;
// check current/previous point crossing
if (check_cross(x, y, p1, p2)) {
side = 1 - side;
}
p1 = p2;
}
// check last/first point crossing
if (check_cross(x, y, p1, pf)) {
side = 1 - side;
}
// check inner polygons only if inside the outer polygon
if (side)
for (Uint16 i=0; i<poly->inners; i++) {
// if inside an inner, then actually outside poly and term
if (is_inside_poly(x, y, m, o)) {
side = 0;
break;
}
}
if (side) {
Uint32 impos = y + (x * height);
(m + o)[impos] = 255;
}
}
}
// update pointer to next polygon
readoff = nextpoly;
}
/**
* m = memory base pointer
* i = input memory location (polygon records)
* o = output memory location (for raster)
* returns last read position in memory
*/
EMSCRIPTEN_KEEPALIVE
Uint32 render(unsigned char *m, Uint32 i, Uint32 o) {
struct info *info = (struct info *)(m + i);
Uint32 impos;
Uint8 inside;
Uint32 polypos = (sizeof (struct info)) + i;
memset(m+o, 0, info->width * info->height);
readoff = polypos;
for (Uint32 p=0; p<info->polys; p++) {
rasterize_poly(m, o, info->height);
}
return readoff;
}
Uint8 rle_byte(Uint8 color, Uint8 count, Uint8 type) {
if (type == 0) {
return (count & 0x7f) | ((color << 7) & 0x80);
} else {
Uint8 run = count - 1;
return
(run & 1 ? 128 : 0) |
(run & 2 ? 64 : 0) |
(run & 4 ? 32 : 0) |
(run & 8 ? 16 : 0) |
(run & 16 ? 8 : 0) |
(run & 32 ? 4 : 0) |
(run & 64 ? 2 : 0) | color;
}
}
/**
* mem = memory base pointer
* in = input memory location (raster)
* ilen = input raster length in bytes
* out = output memory location (for rle-encoded image)
* type = 0=photon, 1=photons
* returns length of rle-encoded image
*/
EMSCRIPTEN_KEEPALIVE
Uint32 rle_encode(unsigned char *mem, Uint32 in, Uint32 ilen, Uint8 mask, Uint32 out, Uint8 type) {
Uint8 color = (mem[in++] & mask) ? 1 : 0; // current color
Uint8 count = 1; // number of color matches
Uint8 cmax = type == 0 ? 125 : 128; // count max
Uint8 next; // next color
Uint32 opos = out;
while (--ilen > 0) {
next = (mem[in++] & mask) ? 1 : 0;
if (color != next || count == cmax) {
mem[opos++] = rle_byte(color, count, type);
count = 0;
}
count++;
color = next;
}
if (count > 0) {
mem[opos++] = rle_byte(color, count, type);
}
return opos - out;
}

BIN
src2/wasm/kiri-sla.wasm Executable file

Binary file not shown.

13
src2/wasm/makefile Normal file
View file

@ -0,0 +1,13 @@
all: kiri-sla.wasm kiri-geo.wasm kiri-ani.wasm
kiri-sla.wasm: kiri-sla.c
emcc --no-entry -o kiri-sla.wasm kiri-sla.c -O3 -s ERROR_ON_UNDEFINED_SYMBOLS=0 -s TOTAL_MEMORY=40mb
kiri-geo.wasm: kiri-geo.cpp
emcc --no-entry -o kiri-geo.wasm clipper.cpp kiri-geo.cpp -Oz -s ERROR_ON_UNDEFINED_SYMBOLS=0 -s TOTAL_MEMORY=40mb
kiri-ani.wasm: kiri-ani.c
emcc --no-entry -o kiri-ani.wasm kiri-ani.c -O3 -s ERROR_ON_UNDEFINED_SYMBOLS=0
clean: kiri-*.wasm
rm *.wasm

1
src2/wasm/manifold.wasm Symbolic link
View file

@ -0,0 +1 @@
../../node_modules/manifold-3d/manifold.wasm

View file

@ -0,0 +1,23 @@
import * as THREE from "../../node_modules/three/build/three.module.js";
import * as SVGLoader from "../../node_modules/three/examples/jsm/loaders/SVGLoader.js";
import * as BufferGeometryUtils from "../../node_modules/three/examples/jsm/utils/BufferGeometryUtils.js";
import { Line2 } from '../../node_modules/three/examples/jsm/lines/Line2.js';
import { LineMaterial } from '../../node_modules/three/examples/jsm/lines/LineMaterial.js';
import { LineGeometry } from '../../node_modules/three/examples/jsm/lines/LineGeometry.js';
import { LineSegments2 } from '../../node_modules/three/examples/jsm/lines/LineSegments2.js';
import { LineSegmentsGeometry } from '../../node_modules/three/examples/jsm/lines/LineSegmentsGeometry.js';
import * as MeshBVHLib from '../../node_modules/three-mesh-bvh/build/index.module.js';
export {
THREE,
SVGLoader,
BufferGeometryUtils,
LineMaterial,
Line2,
LineGeometry,
LineSegments2,
LineSegmentsGeometry,
MeshBVHLib
};

View file

@ -0,0 +1,38 @@
const path = require('path');
module.exports = {
mode: 'production',
entry: path.resolve(__dirname, './webpack-three-bundle.js'),
output: {
path: path.resolve(__dirname, '../ext'),
filename: 'three.js',
library: {
type: 'module'
},
module: true
},
experiments: {
outputModule: true
},
resolve: {
extensions: ['.mjs', '.js'],
},
module: {
rules: [
{
test: /\.m?js$/,
resolve: {
fullySpecified: false,
},
},
],
},
optimization: {
minimize: false
},
performance: {
hints: false,
maxAssetSize: 2 * 1024 * 1024,
maxEntrypointSize: 2 * 1024 * 1024,
},
};

31
web2/README.md Normal file
View file

@ -0,0 +1,31 @@
# Mesh:Tool Development Server
This is the development server setup for Mesh:Tool, a browser-based 3D mesh editor.
## Running the Development Server
1. Make sure you have Node.js installed
2. Install serve globally (optional):
```bash
npm install -g serve
```
3. Run the development server:
```bash
npx serve -l 5432
```
4. Open your browser to http://localhost:5432/mesh
## Development Notes
- The server is configured to handle symlinks and path mappings through `serve.json`
- CORS headers are set to allow WebAssembly modules to load properly
- Static assets are served from the `lib` directory
- Source code is served from the `src2` directory
- Fonts are served from `/font` and `/fon2` directories
## Troubleshooting
If you encounter CORS or WebAssembly loading issues:
1. Make sure you're using a modern browser that supports SharedArrayBuffer
2. Check that the CORS headers are being applied correctly
3. Verify that all paths in index.html are correct for your setup

1
web2/fon2 Symbolic link
View file

@ -0,0 +1 @@
../node_modules/bootstrap-icons/font/

1
web2/font Symbolic link
View file

@ -0,0 +1 @@
../node_modules/@fortawesome/fontawesome-free/

1
web2/lib Symbolic link
View file

@ -0,0 +1 @@
../src2/

BIN
web2/mesh/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

830
web2/mesh/index.css Normal file
View file

@ -0,0 +1,830 @@
:root {
--menu-blue: #0079ff;
--menu-back: rgba(255,255,255,0.55);
--dark-menu-back: rgba(80,80,80,0.75);
--border: #888;
--dark-border: #888;
--selected: rgba(0,255,0,0.5);
--selected-hover: rgba(0,255,0,0.8);
}
@font-face {
font-family: 'Russo One';
src: url('/moto/russo-one.ttf');
}
/** defaults */
a, a:hover, a:visited {
border: none;
color: inherit;
text-decoration: none;
}
body, div {
font-family: system-ui, Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
position: relative;
display: flex;
padding: 0;
border: 0;
margin: 0;
white-space: nowrap;
}
hr {
width: 100%;
border: 0;
margin: 0;
padding: 0;
height: 0.5px;
max-height: 0.5px;
border-top: 0.5px solid rgba(0,0,0,0.5);
}
input {
background-color: #eee;
border-radius: 3px;
border: 1px solid black;
}
#app {
position: fixed;
overflow: hidden;
flex-direction: column;
font-family: sans-serif;
font-weight: normal;
font-size: larger;
bottom: 0;
right: 0;
left: 0;
top: 0;
}
#app-body {
flex-grow: 1;
}
#app-body > div {
z-index: 2;
}
#curtain {
z-index: 50000;
top: 0;
left: 0;
right: 0;
bottom: 0;
position: fixed;
background-color: #fff;
font-family: 'Russo One', sans-serif;
justify-content: center;
align-items: center;
}
#container {
z-index: 1;
position: fixed;
width: 100%;
height: 100%;
}
#container canvas {
width: 100%;
height: 100%;
overflow: hidden;
}
.dark #top {
color: #eee;
background-color: var(--dark-menu-back);
}
#top {
z-index: 50;
color: #000;
font-size: 14px;
flex-direction: row;
background-color: var(--menu-back);
border-bottom: 1px solid var(--border);
}
.noselect {
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
:-moz-any() {
overflow-x: hidden !important;
overflow-y: hidden !important;
}
:-moz-any() {
overflow-x: scroll !important;
overflow-y: scroll !important;
margin-right: -14px !important;
margin-bottom: -14px !important;
}
button {
padding: 2px 3px 2px 3px;
border: 1px solid rgba(150,150,150,0.5);
background-color: rgba(220,220,220,0.75);
user-select: none;
outline: none;
}
.dark button:hover {
background-color: rgba(220,220,220,1);
}
button:hover {
background-color: rgba(210,210,210,1);
}
/** misc ui **/
.hide {
display: none;
}
.pad3 {
width: 3px;
height: 3px;
}
.pad5 {
width: 5px;
height: 5px;
}
#top-left {
margin-left: 5px;
flex-direction: row;
padding: 0 !important;
}
.menu, #top-right > div {
cursor: default;
align-self: stretch;
align-items: center;
padding: 8px 12px 8px 12px;
border-radius: 6px;
user-select: none;
}
.dark .menu hr {
border-top: 0.5px solid rgba(255,255,255,0.5);
}
.menu:hover, .menu-items > div:hover, .tools i:hover {
background-color: var(--menu-blue);
}
.menu:hover .menu-items {
display: flex;
}
.dark .menu-items {
background: #666;
border: 1px solid #999;
}
.menu-items {
display: none;
position: absolute;
flex-direction: column;
border: 1px solid #bbb;
border-radius: 6px;
background: #eee;
padding: 4px;
top: 100%;
left: 0;
gap: 6px;
}
.menu-items > div {
gap: 15px;
border-radius: 6px;
padding: 4px 8px 4px 8px;
}
.menu-items .grow {
flex-grow: 1;
}
.menu-items .short {
gap: 1px;
}
#top-left:not(:hover) #mode-label {
display: flex;
}
#mode-label {
display: none;
position: absolute;
text-transform: capitalize;
padding: 2px 4px 2px 4px;
background-color: rgba(0,200,0,0.35);
border: 1px solid rgba(0,0,0,0.25);
border-radius: 3px;
transform: translateX(-50%);
top: calc(100% + 2px);
left: 50%;
}
#mode-label:before {
content: '';
position: absolute;
top: -10px; /* Adjust to position the caret above the menu */
left: 50%;
transform: translateX(-50%);
border-width: 5px;
border-style: solid;
border-color: transparent transparent #777 transparent;
}
.dark #mode-label:before {
border-color: transparent transparent #ddd transparent;
}
.dark #mode-label {
background-color: rgba(0,200,0,0.65);
border-color: rgba(255,255,255,0.25);
}
#top-mid {
flex-grow: 1;
}
#top-right {
flex-direction: row;
margin-right: 5px;
padding: 0 !important;
}
#top-right > div {
gap: 8px;
}
/** specific modal dialogs */
.export {
flex-direction: column;
font-family: monospace;
}
.export, .export div {
gap: 4px;
align-items: center;
justify-content: center;
}
.export input, .export button {
font-family: monospace;
padding: 3px;
}
.export button {
padding: 4px;
}
.addact {
gap: 4px;
}
.addact input, .additem input {
text-align: center;
}
.additem {
gap: 5px 10px;
display: grid;
grid-template-columns: 1fr 50px;
}
/* .additem hr {
width: 100%;
} */
.additem button, .additem hr, .additem code {
grid-column: span 2;
text-align: center;
}
.image-import {
flex-direction: column;
gap: 3px;
}
.image-import label {
padding-right: 5px;
flex-grow: 1;
}
.image-import input {
text-align: center;
}
.image-import button {
margin: 10px 20px 1px 20px;
flex-grow: 1;
}
/** generic modal dialogs **/
#modal_page {
background-color: rgba(0,0,0,0.25);
justify-content: center;
align-items: center;
position: absolute;
z-index: 500 !important;
bottom: 0;
right: 0;
left: 0;
top: 0;
display: none;
}
#modal_frame {
border-radius: 4px !important;
background-color: #fff !important;
flex-direction: column;
padding: 0 !important; /* override common below */
/* z-index: 51; */
gap: 2px;
}
#modal_frame > div {
flex-direction: column;
padding: 5px; /* compensate for removing padding above */
}
#modal_title {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
border-bottom: 1px solid gray;
background-color: rgba(0,0,0,0.2);
font-family: 'Russo One', monospace;
font-size: smaller;
justify-content: center;
align-items: center;
}
#modal_title_close {
position: absolute;
color: #555;
right: 3px;
top: 2px;
}
#modal_title_close:hover {
color: black;
}
/** welcome dialog **/
.welcome {
font-size: smaller;
flex-direction: column;
align-items: center;
padding: 5px;
gap: 5px;
}
.welcome a {
color: #038;
border-radius: 3px;
padding: 0 10px 0 10px;
}
.welcome a:hover {
background-color: #ddd;
}
.welcome .choice {
gap: 5px
}
/** settings dialog **/
.settings {
gap: 5px;
display: grid;
background-color: #fff;
grid: min-content 1fr / min-content 1fr;
grid-template-columns: 1fr 1fr 1fr;
}
.settings > div {
display: grid;
background-color: #f5f5f5;
grid: min-content 1fr / min-content 1fr;
white-space: nowrap;
align-items: center;
border: 1px solid #ddd;
border-radius: 3px;
padding: 3px;
}
.settings .header {
grid-column: 1 / 3;
text-align: center;
margin-bottom: 5px;
border-radius: 3px;
background-color: #ccc;
padding: 0;
}
.settings label {
padding: 0 5px 0 3px;
}
.settings input {
font-family: monospace;
text-align: center;
background: #fff;
justify-self: end;
}
/** spinner animation **/
@keyframes spinner {
0% {
transform: translate3d(-50%, -50%, 0) rotate(0deg);
}
100% {
transform: translate3d(-50%, -50%, 0) rotate(360deg);
}
}
.spin::before {
position: absolute;
animation: 2.0s linear infinite spinner;
animation-play-state: inherit;
border: solid 10px #cfd0d1;
border-bottom-color: #1c87c9;
border-radius: 50%;
content: "";
height: 50px;
width: 50px;
top: 10%;
left: 10%;
transform: translate3d(-50%, -50%, 0);
will-change: transform;
}
/** common look & feel */
.dark #modal_frame, .dark #actions > div, .dark #grouplist > div {
border: 1px solid #999;
}
#modal_frame, #actions > div, #grouplist > div {
border: 1px solid gray;
border-radius: 3px;
margin: 0 0 2px 0;
padding: 3px;
background-color: rgba(255,255,255,0.5);
}
#actions .vsep, #grouplist .vsep {
margin: 1px 0 1px 0;
border-bottom: 1px dashed gray;
}
.menu .selected {
background-color: var(--selected);
}
.menu .selected:hover {
background-color: var(--selected-hover);
}
/* .dark .head {
color: #000 !important;
} */
/** slide in/out logging window **/
.dark #logger {
border-color: var(--dark-border);
background-color: var(--dark-menu-back);
color: #ddd;
}
#logger {
border-top: 1px solid var(--border);
border-right: 1px solid var(--border);
background-color: var(--menu-back);
display: none;
color: #555;
min-width: 300px;
flex-direction: column;
position: absolute;
margin: 0 0 0 1px;
bottom: 0;
left: 100%;
padding: 5px;
}
#logtext {
flex-direction: column;
max-height: 8em;
min-height: 1em;
overflow-y: auto;
}
#logtext > div {
font-family: monospace;
font-size: 12px;
}
#logtext::-webkit-scrollbar {
width: 6px;
}
#logtext::-webkit-scrollbar-track {
background: transparent;
}
#logtext::-webkit-scrollbar-thumb {
border-radius: 3px;
background-color: #ddd;
border: 3px solid rgba(255,255,255,0.1);
}
/** left menu sketch and object tool menus */
#tools {
border-right: 1px solid var(--border);
}
.dark .tools {
border-color: var(--dark-border);
background-color: var(--dark-menu-back);
}
.dark .tools > div {
color: #fff;
}
.tools {
align-items: flex-start;
background-color: var(--menu-back);
}
.tools > div {
display: grid;
grid-template-columns: 1fr;
}
.tools i {
padding: 9px;
border-bottom: 1px solid var(--border);
}
.tool > div {
display: none;
position: absolute;
padding-left: 10px;
left: 100%;
bottom: 0;
top: 0;
place-items: center; /* Center the single grid item */
height: 100%;
width: 100%;
}
.dark .tool label {
background-color: var(--dark-menu-back);
border: 1px solid var(--dark-border);
}
.tool label:before {
content: '';
position: absolute;
left: -2px; /* Adjust to position the caret */
top: 50%;
transform: translateY(-50%);
border-width: 6px;
border-style: solid;
border-color: transparent #777 transparent transparent;
}
.dark .tool label:before {
border-color: transparent #ddd transparent transparent;
}
.tool label {
font-size: 12px !important;
background-color: var(--menu-back);
border: 1px solid var(--border);
border-radius: 4px;
padding: 4px 6px 4px 6px;
}
.tool:hover > div {
display: grid;
}
/** group/model buttons top/right of workspace **/
#grouplist {
position: absolute;
margin: 3px 3px 0 0;
right: 0;
top: 0;
}
#grouplist .selected {
background-color: var(--selected);
}
#grouplist .selected:hover {
background-color: var(--selected-hover);
}
#grouplist, #grouplist div {
flex-direction: column;
gap: 2px;
}
#grouplist .group {
font-weight: bold;
padding: 3px;
}
#grouplist .models {
display: grid;
grid-template-columns: auto min-content;
max-height: 200px;
overflow-y: auto;
gap: 1px;
}
#grouplist .models .square {
display: flex;
align-items: center;
justify-content: center;
margin: 0;
}
.dark #grouplist .models .square > svg {
color: #333;
}
#grouplist .models .square > svg {
aspect-ratio: 1;
max-height: 20px;
color: #666;
}
#grouplist .models > div {
flex-direction: row;
}
#grouplist .models button {
text-align: left;
}
/** selection info bottom/right of workspace **/
#selectlist {
font-size: smaller;
position: absolute;
margin: 0 3px 3px 0;
bottom: 0;
right: 0;
gap: 2px;
}
#selectlist > .side {
position: relative;
width: 1.75em;
display: none;
}
#selectlist > .side > div {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(-90deg);
padding-bottom: 2px;
}
#selectlist > div {
flex-direction: column;
gap: 2px;
}
#selectlist > button, #selectlist > div > button {
border-radius: 3px;
}
#selectlist label {
border-radius: 3px;
font-family: monospace;
text-align: right;
padding: 2px;
background-color: rgba(0,0,0,0.05);
}
#selectlist .top {
display: block;
font-weight: bold;
font-size: smaller;
text-align: center;
}
#selectlist .side {
font-weight: bold;
font-size: smaller;
align-items: center;
justify-content: flex-end;
}
#selectlist .grid {
background-color: rgba(255,255,255,0.8);
border: 1px solid rgba(0,0,0,0.15);
border-radius: 3px;
padding: 3px;
display: grid;
flex-grow: 1;
gap: 2px 2px;
grid: min-content 1fr 1fr / min-content 1fr 1fr 1fr;
}
#selectlist .grid0 {
display: flex;
flex-grow: 1;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 5px;
}
#selectlist .grid1 {
grid: min-content 1fr / min-content 1fr;
}
#selectlist .grid2 {
grid: min-content 1fr 1fr / min-content 1fr 1fr;
}
.grid .ungrouped {
background-color: rgba(128,128,128,0.5) !important;
}
.grid .ungrouped:hover {
color: #fff !important;
}
.tempedit, .rename {
gap: 3px;
}
.tempedit input {
text-align: center;
}
.editable {
color: #05a;
cursor: default;
}
.editable:hover {
color: #000;
background-color: rgba(0,128,255,0.5) !important;
}
#pinner-box {
position: absolute;
z-index: 5000;
left: 50%;
bottom: 50%;
transform: translate(-50%, 50%);
}
.dark #pinner {
background-color: rgba(255,255,255,0.15);
border-color: rgba(255,255,255,0.25);
border-top-color: rgba(255,255,255,0.6);
}
#pinner {
z-index: 5000;
display: none;
width: 50px;
height: 50px;
background-color: rgba(0,0,0,0.1);
border: 12px solid rgba(0,0,0,0.15);
border-radius: 50%;
border-top-color: rgba(0,0,0,0.6);
animation: spin 1.5s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg) }
}

38
web2/mesh/index.html Normal file
View file

@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="author" content="Stewart Allen">
<meta name="keywords" content="3d,mesh,editor,stl,obj,3mf" />
<meta name="copyright" content="stewart allen [sa@grid.space]">
<meta name="description" content="Browser-based 3D Mesh Editor">
<meta property="og:description" content="Mesh:Tool is a browser-based 3D Mesh Editor">
<meta property="og:title" content="Browser-based Mesh Editor">
<meta property="og:type" content="website">
<meta property="og:url" content="https://grid.space/mesh/">
<title>Mesh:Tool</title>
<link rel="icon" href="favicon.ico">
<link rel="stylesheet" type="text/css" href="index.css">
<link href="../font/css/all.min.css" rel="stylesheet">
<link href="../fon2/bootstrap-icons.min.css" rel="stylesheet">
<script src="../lib/ext/earcut.js" type="module"></script>
<script src="../lib/ext/tween.js"></script>
<script src="../font/js/all.min.js" crossorigin="anonymous" async></script>
<script src="../lib/main/mesh.js" type="module"></script>
</head>
<body>
<div id="app">
<div id="curtain"><img id="loading"/>loading...</div>
<div id="container"></div>
<div id="modal_page"></div>
<div id="top">
<div id="top-left"></div>
<div id="top-mid"></div>
<div id="top-right"></div>
</div>
<div id="top-sep"></div>
<div id="app-body"></div>
<div id="pinner-box"><div id="pinner"></div></div>
</div>
</body>
</html>

BIN
web2/moto/russo-one.ttf Normal file

Binary file not shown.

17
web2/serve.json Normal file
View file

@ -0,0 +1,17 @@
{
"headers": [
{
"source": "**/*",
"headers": [
{
"key": "Cross-Origin-Embedder-Policy",
"value": "require-corp"
},
{
"key": "Cross-Origin-Opener-Policy",
"value": "same-origin"
}
]
}
]
}