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

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;