2021-12-29 16:49:15 -05:00
|
|
|
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
|
|
|
|
|
|
|
|
|
|
"use strict";
|
|
|
|
|
|
2022-02-20 12:56:43 -05:00
|
|
|
// dep: add.array
|
|
|
|
|
// dep: add.three
|
2024-08-12 15:54:49 -04:00
|
|
|
// dep: geo.polygon
|
|
|
|
|
// dep: geo.polygons
|
2022-02-20 12:56:43 -05:00
|
|
|
// dep: moto.license
|
|
|
|
|
// dep: moto.client
|
|
|
|
|
// dep: mesh.object
|
|
|
|
|
// use: mesh.api
|
|
|
|
|
// use: mesh.util
|
|
|
|
|
// use: mesh.group
|
|
|
|
|
gapp.register("mesh.model", [], (root, exports) => {
|
|
|
|
|
|
2024-07-25 14:48:36 -04:00
|
|
|
const { MeshPhongMaterial, MeshBasicMaterial, LineBasicMaterial } = THREE;
|
2024-08-12 15:54:49 -04:00
|
|
|
const { BufferGeometry, BufferAttribute, DoubleSide, Mesh, Vector3, Triangle } = THREE;
|
|
|
|
|
const { base, mesh, moto } = root;
|
2022-02-20 12:56:43 -05:00
|
|
|
const { space } = moto;
|
2024-07-26 16:28:52 -04:00
|
|
|
const { api } = mesh;
|
2024-08-12 15:54:49 -04:00
|
|
|
const { newPolygon, polygons } = base;
|
2022-02-20 12:56:43 -05:00
|
|
|
|
|
|
|
|
const mapp = mesh;
|
|
|
|
|
const worker = moto.client.fn;
|
2024-08-09 21:53:33 -04:00
|
|
|
const zero = new Vector3(0,0,0);
|
2022-01-02 14:25:28 -05:00
|
|
|
|
2021-12-29 21:52:24 -05:00
|
|
|
/** default materials **/
|
2021-12-31 17:14:55 -05:00
|
|
|
let materials = mesh.material = {
|
2022-01-13 11:59:26 -05:00
|
|
|
// model unselected
|
|
|
|
|
normal: new MeshPhongMaterial({
|
2022-11-15 22:15:14 -05:00
|
|
|
flatShading: true,
|
2022-01-12 11:46:10 -05:00
|
|
|
side: DoubleSide,
|
2021-12-29 17:20:17 -05:00
|
|
|
transparent: true,
|
2022-01-19 21:39:11 -05:00
|
|
|
shininess: 125,
|
2022-01-05 17:58:37 -05:00
|
|
|
specular: 0x202020,
|
2022-01-15 15:04:53 -05:00
|
|
|
color: 0xf0f000,
|
2021-12-29 17:20:17 -05:00
|
|
|
opacity: 1
|
2021-12-31 17:14:55 -05:00
|
|
|
}),
|
2022-01-13 11:59:26 -05:00
|
|
|
// model selected
|
|
|
|
|
select: new MeshPhongMaterial({
|
2022-11-15 22:15:14 -05:00
|
|
|
flatShading: true,
|
2022-01-13 11:59:26 -05:00
|
|
|
side: DoubleSide,
|
|
|
|
|
transparent: true,
|
2022-01-19 21:39:11 -05:00
|
|
|
shininess: 125,
|
2022-01-13 11:59:26 -05:00
|
|
|
specular: 0x202020,
|
2022-01-15 15:04:53 -05:00
|
|
|
color: 0x00e000,
|
2022-01-13 11:59:26 -05:00
|
|
|
opacity: 1
|
|
|
|
|
}),
|
2022-04-20 16:53:28 -04:00
|
|
|
// model selected as tool
|
|
|
|
|
tool: new MeshPhongMaterial({
|
2022-11-15 22:15:14 -05:00
|
|
|
flatShading: true,
|
2022-04-20 16:53:28 -04:00
|
|
|
side: DoubleSide,
|
|
|
|
|
transparent: true,
|
|
|
|
|
shininess: 125,
|
|
|
|
|
specular: 0x202020,
|
|
|
|
|
color: 0xe00000,
|
|
|
|
|
opacity: 1
|
|
|
|
|
}),
|
2022-01-13 11:59:26 -05:00
|
|
|
// face selected (for groups ranges)
|
|
|
|
|
face: new MeshPhongMaterial({
|
2022-11-15 22:15:14 -05:00
|
|
|
flatShading: true,
|
2022-01-12 11:46:10 -05:00
|
|
|
side: DoubleSide,
|
2021-12-31 17:14:55 -05:00
|
|
|
transparent: true,
|
|
|
|
|
shininess: 100,
|
2022-01-05 17:58:37 -05:00
|
|
|
specular: 0x202020,
|
2022-01-14 16:45:40 -05:00
|
|
|
color: 0x0088ee,
|
2021-12-31 17:14:55 -05:00
|
|
|
opacity: 1
|
2022-01-03 21:14:36 -05:00
|
|
|
}),
|
2022-01-12 11:46:10 -05:00
|
|
|
wireframe: new MeshBasicMaterial({
|
|
|
|
|
side: DoubleSide,
|
2022-01-03 21:14:36 -05:00
|
|
|
wireframe: true,
|
2022-01-14 14:14:13 -05:00
|
|
|
color: 0x0,
|
|
|
|
|
transparent: true,
|
|
|
|
|
opacity: 0.5
|
2022-01-03 21:14:36 -05:00
|
|
|
}),
|
2024-07-25 14:48:36 -04:00
|
|
|
wireline: new LineBasicMaterial({
|
|
|
|
|
side: DoubleSide,
|
|
|
|
|
color: 0x0,
|
|
|
|
|
transparent: true,
|
|
|
|
|
opacity: 0.5
|
|
|
|
|
})
|
2021-12-29 17:20:17 -05:00
|
|
|
};
|
|
|
|
|
|
2021-12-29 21:52:24 -05:00
|
|
|
/** 3D model rendered on plaform **/
|
2021-12-31 13:26:07 -05:00
|
|
|
mesh.model = class MeshModel extends mesh.object {
|
2022-01-03 17:13:50 -05:00
|
|
|
constructor(data, id) {
|
|
|
|
|
super(id);
|
2024-08-02 15:35:11 -04:00
|
|
|
let { file, mesh, vertices } = data;
|
2021-12-29 16:49:15 -05:00
|
|
|
|
|
|
|
|
if (!mesh) {
|
|
|
|
|
dbug.error(`'${file}' missing mesh data`);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2022-01-02 21:39:09 -05:00
|
|
|
// remove file name extensions
|
|
|
|
|
let text = file || '';
|
|
|
|
|
let dot = text.lastIndexOf('.');
|
|
|
|
|
if (dot > 0) file = text.substring(0, dot);
|
|
|
|
|
|
2022-01-13 11:59:26 -05:00
|
|
|
// create local materials
|
|
|
|
|
this.mats = {
|
|
|
|
|
normal: materials.normal.clone(),
|
|
|
|
|
select: materials.select.clone(),
|
2022-04-20 16:53:28 -04:00
|
|
|
tool: materials.tool.clone(),
|
2022-01-13 11:59:26 -05:00
|
|
|
face: materials.face.clone()
|
|
|
|
|
};
|
|
|
|
|
|
2022-01-13 20:23:48 -05:00
|
|
|
// 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 }
|
|
|
|
|
};
|
|
|
|
|
|
2022-01-02 21:39:09 -05:00
|
|
|
this.file = file || 'unnamed';
|
2024-08-02 15:35:11 -04:00
|
|
|
this.load(mesh || vertices);
|
2021-12-29 17:20:17 -05:00
|
|
|
}
|
|
|
|
|
|
2022-01-05 17:58:37 -05:00
|
|
|
get type() {
|
2022-01-02 14:25:28 -05:00
|
|
|
return "model";
|
|
|
|
|
}
|
|
|
|
|
|
2022-01-05 17:58:37 -05:00
|
|
|
get object() {
|
2021-12-31 13:26:07 -05:00
|
|
|
return this.mesh;
|
2021-12-30 22:59:46 -05:00
|
|
|
}
|
|
|
|
|
|
2024-08-09 21:53:33 -04:00
|
|
|
get bounds() {
|
|
|
|
|
return this.geometry.boundingBox.clone();
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-09 23:58:36 -04:00
|
|
|
get world_bounds() {
|
|
|
|
|
return this.bounds.translate(this.position());
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-10 14:34:16 -04:00
|
|
|
// get world_positions() {
|
|
|
|
|
// let pos = this.position();
|
|
|
|
|
// return this.geometry
|
|
|
|
|
// .clone()
|
|
|
|
|
// .translate(pos.x,pos.y,pos.z)
|
|
|
|
|
// .attributes.position.array;
|
|
|
|
|
// }
|
2024-08-10 11:40:24 -04:00
|
|
|
|
2024-08-10 13:24:01 -04:00
|
|
|
get positions() {
|
|
|
|
|
return this.geometry.attributes.position.array;
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-10 14:34:16 -04:00
|
|
|
// get matrix() {
|
|
|
|
|
// return this.matrixWorld.elements;
|
|
|
|
|
// }
|
2022-01-08 21:00:19 -05:00
|
|
|
|
2022-05-13 15:05:21 -04:00
|
|
|
get matrixWorld() {
|
|
|
|
|
return this.mesh.matrixWorld;
|
|
|
|
|
}
|
|
|
|
|
|
2024-07-26 16:28:52 -04:00
|
|
|
drag(opt = {}) {
|
|
|
|
|
let { start, delta, offset, end } = opt;
|
|
|
|
|
let { snap, snapon } = api.prefs.map.space;
|
|
|
|
|
if (start) {
|
|
|
|
|
let mid = this.bounds.mid;
|
|
|
|
|
this._save = {
|
2024-07-26 19:31:32 -04:00
|
|
|
pos: Object.assign({}, mid),
|
|
|
|
|
start: Object.assign({}, mid),
|
2024-07-26 16:28:52 -04:00
|
|
|
}
|
|
|
|
|
} 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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-09 21:53:33 -04:00
|
|
|
qrotate(quaternion) {
|
|
|
|
|
this.log('model-rotate', quaternion.toArray());
|
|
|
|
|
this.geometry.applyQuaternion(quaternion);
|
|
|
|
|
this.updateBounds();
|
2024-08-03 10:26:25 -04:00
|
|
|
}
|
|
|
|
|
|
2024-08-09 21:53:33 -04:00
|
|
|
scale(x = 1, y = 1, z = 1) {
|
|
|
|
|
if (x === 1 && y === 1 && z === 1) return;
|
2024-08-10 15:19:52 -04:00
|
|
|
this.log('model-scale', ...arguments, this.bounds);
|
2024-08-09 21:53:33 -04:00
|
|
|
this.geometry.scale(x, y, z);
|
|
|
|
|
this.updateBounds();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
translate(x = 0, y = 0, z = 0) {
|
|
|
|
|
if (!(x || y || z)) return;
|
2024-08-10 15:19:52 -04:00
|
|
|
this.log('model-translate', ...arguments);
|
2024-08-09 21:53:33 -04:00
|
|
|
this.geometry.translate(x, y, z);
|
|
|
|
|
this.updateBounds();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
move(x = 0, y = 0, z = 0) {
|
|
|
|
|
if (!(x || y || z)) return;
|
2024-08-10 15:19:52 -04:00
|
|
|
this.log('model-move', ...arguments);
|
2024-08-09 21:53:33 -04:00
|
|
|
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;
|
2022-01-20 00:01:48 -05:00
|
|
|
}
|
2024-08-09 21:53:33 -04:00
|
|
|
pos.set(...arguments);
|
2024-08-10 13:24:01 -04:00
|
|
|
this.metaChanged({ pos: pos.toArray() });
|
2022-01-20 00:01:48 -05:00
|
|
|
return this;
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-09 21:53:33 -04:00
|
|
|
// 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() {
|
2024-08-09 23:58:36 -04:00
|
|
|
this.log('model-recenter');
|
2024-08-09 21:53:33 -04:00
|
|
|
let pos = this.position();
|
|
|
|
|
let { mid } = this.bounds;
|
|
|
|
|
this.translate(-mid.x, -mid.y, -mid.z);
|
2024-08-09 23:58:36 -04:00
|
|
|
this.position(mid.x + pos.x, mid.y + pos.y, mid.z + pos.z);
|
2022-01-20 00:01:48 -05:00
|
|
|
return this;
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-09 21:53:33 -04:00
|
|
|
// preserves world location while updating mesh for rotation and scaling
|
2024-08-09 23:58:36 -04:00
|
|
|
// 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;
|
2024-08-10 11:40:24 -04:00
|
|
|
let abs = this.world_bounds.mid;
|
2024-08-09 23:58:36 -04:00
|
|
|
this.translate(
|
|
|
|
|
-mid.x + (abs.x - to.x),
|
|
|
|
|
-mid.y + (abs.y - to.y),
|
|
|
|
|
-mid.z + (abs.z - to.z),
|
|
|
|
|
);
|
|
|
|
|
this.position(to.x, to.y, to.z);
|
|
|
|
|
return this;
|
2024-08-09 21:53:33 -04:00
|
|
|
}
|
|
|
|
|
|
2024-08-10 15:19:52 -04:00
|
|
|
// when geometry updates, recompute bounds for ray intersections
|
|
|
|
|
// and sync data to worker and indexed db
|
2024-08-09 21:53:33 -04:00
|
|
|
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);
|
2024-08-10 16:50:12 -04:00
|
|
|
this.normals({ refresh: true });
|
2024-08-09 21:53:33 -04:00
|
|
|
this.updateBoundsBox();
|
|
|
|
|
moto.space.update();
|
2024-08-10 13:24:01 -04:00
|
|
|
this.sync();
|
2024-08-09 21:53:33 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updateBoundsBox() {
|
|
|
|
|
this.group?.updateBoundsBox();
|
|
|
|
|
}
|
|
|
|
|
|
2022-01-20 00:01:48 -05:00
|
|
|
mirror() {
|
|
|
|
|
return this.duplicate({ mirror: true });
|
|
|
|
|
}
|
|
|
|
|
|
2024-07-20 09:59:22 -04:00
|
|
|
// 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
|
2024-07-21 22:36:37 -04:00
|
|
|
duplicate(opt = { select: true }) {
|
2024-08-09 21:53:33 -04:00
|
|
|
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 mesh.model({ file: `${this.file}`, mesh: data });
|
|
|
|
|
let group = opt.group || mesh.api.group.new();
|
|
|
|
|
group.add(model);
|
|
|
|
|
model.position(pos.x, pos.y, pos.z);
|
|
|
|
|
model.wireframe(this.wireframe());
|
|
|
|
|
if (opt.select) {
|
|
|
|
|
api.selection.add(model);
|
|
|
|
|
}
|
|
|
|
|
if (opt.mirror) {
|
|
|
|
|
group.move(0, 0, group.bounds.dim.z);
|
|
|
|
|
} else if (opt.shift) {
|
|
|
|
|
group.move(group.bounds.dim.x, 0, 0);
|
|
|
|
|
}
|
|
|
|
|
return model;
|
2021-12-31 22:11:20 -05:00
|
|
|
}
|
|
|
|
|
|
2024-08-02 15:35:11 -04:00
|
|
|
load(vertices) {
|
2024-08-09 21:53:33 -04:00
|
|
|
this.log('load');
|
2022-01-12 11:46:10 -05:00
|
|
|
let geo = new BufferGeometry();
|
2024-08-09 21:53:33 -04:00
|
|
|
geo.setAttribute('position', new BufferAttribute(vertices, 3));
|
2022-01-13 11:59:26 -05:00
|
|
|
let meh = this.mesh = new Mesh(geo, [
|
|
|
|
|
this.mats.normal,
|
|
|
|
|
this.mats.face
|
|
|
|
|
]);
|
|
|
|
|
geo.addGroup(0, Infinity, 0);
|
2021-12-29 17:20:17 -05:00
|
|
|
meh.receiveShadow = true;
|
|
|
|
|
meh.castShadow = true;
|
|
|
|
|
meh.renderOrder = 1;
|
2022-01-06 09:38:04 -05:00
|
|
|
// sets fallback opacity for wireframe toggle
|
|
|
|
|
this.opacity(1);
|
2022-01-01 00:04:35 -05:00
|
|
|
// this ref allows clicks to be traced to models and groups
|
|
|
|
|
meh.model = this;
|
2024-08-10 13:24:01 -04:00
|
|
|
// sync worker, allows raycasting to work
|
2024-08-09 21:53:33 -04:00
|
|
|
this.updateBounds();
|
2021-12-29 16:49:15 -05:00
|
|
|
}
|
2021-12-29 23:52:48 -05:00
|
|
|
|
2024-08-02 15:35:11 -04:00
|
|
|
reload(vertices) {
|
2024-08-09 21:53:33 -04:00
|
|
|
this.log('reload');
|
2022-01-11 14:41:49 -05:00
|
|
|
let was = this.wireframe(false);
|
2022-01-04 08:15:26 -05:00
|
|
|
let geo = this.mesh.geometry;
|
2024-08-09 21:53:33 -04:00
|
|
|
geo.setAttribute('position', new BufferAttribute(vertices, 3));
|
2022-01-18 10:51:02 -05:00
|
|
|
// signal util.box3expand that geometry changed
|
|
|
|
|
geo._model_invalid = true;
|
2022-01-31 13:27:09 -05:00
|
|
|
geo.computeVertexNormals();
|
2022-01-11 14:41:49 -05:00
|
|
|
// restore wireframe state
|
|
|
|
|
this.wireframe(was);
|
2022-01-15 22:24:54 -05:00
|
|
|
// fixup normals
|
|
|
|
|
this.normals({refresh: true});
|
2024-08-10 13:24:01 -04:00
|
|
|
// sync worker, allows raycasting to work
|
|
|
|
|
this.updateBounds();
|
2022-03-31 21:44:47 -04:00
|
|
|
// re-gen face index in surface mode
|
|
|
|
|
mesh.api.mode.check();
|
2022-01-04 08:15:26 -05:00
|
|
|
}
|
2022-01-04 01:11:13 -05:00
|
|
|
|
2022-01-20 17:40:10 -05:00
|
|
|
rename(file) {
|
|
|
|
|
this.file = file;
|
2024-08-10 13:24:01 -04:00
|
|
|
this.sync();
|
2024-08-09 21:53:33 -04:00
|
|
|
}
|
|
|
|
|
|
2024-08-10 13:24:01 -04:00
|
|
|
// 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 });
|
2022-01-20 17:40:10 -05:00
|
|
|
// persist in db so it can be restored on page load
|
2024-08-09 21:53:33 -04:00
|
|
|
mapp.db.space.put(this.id, {
|
|
|
|
|
file: this.file,
|
|
|
|
|
mesh: this.attributes.position.array
|
|
|
|
|
});
|
2022-01-20 17:40:10 -05:00
|
|
|
}
|
|
|
|
|
|
2021-12-29 23:52:48 -05:00
|
|
|
get group() {
|
|
|
|
|
return this._group;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
set group(gv) {
|
2021-12-30 22:59:46 -05:00
|
|
|
if (gv && this._group && this._group !== gv) {
|
|
|
|
|
throw "models can only belong to one group";
|
2021-12-29 23:52:48 -05:00
|
|
|
}
|
|
|
|
|
this._group = gv;
|
|
|
|
|
}
|
2021-12-30 22:59:46 -05:00
|
|
|
|
2024-08-09 21:53:33 -04:00
|
|
|
get geometry() {
|
|
|
|
|
return this.mesh.geometry;
|
|
|
|
|
}
|
|
|
|
|
|
2022-01-05 21:16:18 -05:00
|
|
|
get attributes() {
|
2024-08-09 21:53:33 -04:00
|
|
|
return this.geometry.attributes;
|
2022-01-05 21:16:18 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
get vertices() {
|
2022-01-05 22:09:49 -05:00
|
|
|
return this.attributes.position.count;
|
2022-01-05 21:16:18 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
get faces() {
|
|
|
|
|
return this.vertices / 3;
|
|
|
|
|
}
|
|
|
|
|
|
2022-01-13 11:59:26 -05:00
|
|
|
// get, set, or toggle selection of model (coloring)
|
2022-04-20 16:53:28 -04:00
|
|
|
select(bool, stool) {
|
|
|
|
|
const cmat = this.mesh.material[0];
|
|
|
|
|
const { normal, select, tool } = this.mats;
|
2022-01-13 11:59:26 -05:00
|
|
|
if (bool === undefined) {
|
2022-04-20 16:53:28 -04:00
|
|
|
return cmat !== normal;
|
2022-01-13 11:59:26 -05:00
|
|
|
}
|
|
|
|
|
if (bool.toggle) {
|
2022-04-20 16:53:28 -04:00
|
|
|
return this.select(!this.select(), cmat === tool);
|
2022-01-13 11:59:26 -05:00
|
|
|
}
|
2022-04-20 16:53:28 -04:00
|
|
|
this.mesh.material[0] = bool ? (stool ? tool : select) : normal;
|
2022-01-13 11:59:26 -05:00
|
|
|
return bool;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// return selected state
|
|
|
|
|
selected() {
|
|
|
|
|
return this.select();
|
2022-01-06 09:38:04 -05:00
|
|
|
}
|
|
|
|
|
|
2024-08-10 15:19:52 -04:00
|
|
|
// toggle whether this is a boolean subtraction tool or not
|
2022-04-20 16:53:28 -04:00
|
|
|
tool(bool) {
|
|
|
|
|
if (bool === undefined) {
|
|
|
|
|
return this.mesh.material[0] === this.mats.tool;
|
|
|
|
|
}
|
|
|
|
|
return this.select(this.selected(), bool);
|
|
|
|
|
}
|
|
|
|
|
|
2022-01-06 09:38:04 -05:00
|
|
|
opacity(ov, opt = {}) {
|
2021-12-30 23:23:45 -05:00
|
|
|
let mat = this.mesh.material;
|
|
|
|
|
if (ov === undefined) {
|
2022-01-13 11:59:26 -05:00
|
|
|
return mat[0].opacity;
|
2021-12-30 23:23:45 -05:00
|
|
|
}
|
2022-01-06 09:38:04 -05:00
|
|
|
if (ov.restore) {
|
|
|
|
|
ov = this._op;
|
|
|
|
|
} else if (ov.temp !== undefined) {
|
|
|
|
|
ov = ov.temp;
|
|
|
|
|
} else {
|
|
|
|
|
this._op = ov;
|
|
|
|
|
}
|
2022-01-13 11:59:26 -05:00
|
|
|
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;
|
|
|
|
|
}
|
2021-12-30 23:23:45 -05:00
|
|
|
}
|
2022-01-02 14:25:28 -05:00
|
|
|
space.update();
|
2021-12-30 23:23:45 -05:00
|
|
|
}
|
|
|
|
|
|
2024-07-23 22:42:20 -04:00
|
|
|
highlight() {
|
|
|
|
|
if (!this.wireframe().enabled) {
|
|
|
|
|
this.opacity({temp: 0.5});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
unhighlight() {
|
|
|
|
|
if (!this.wireframe().enabled) {
|
|
|
|
|
this.opacity({restore: true});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2021-12-30 23:23:45 -05:00
|
|
|
wireframe(bool, opt = {}) {
|
2022-01-14 14:14:13 -05:00
|
|
|
let was = this._wire ? true : false;
|
2021-12-30 23:28:36 -05:00
|
|
|
if (bool === undefined) {
|
2022-01-14 14:14:13 -05:00
|
|
|
return was ? {
|
2021-12-30 23:28:36 -05:00
|
|
|
enabled: true,
|
2022-01-06 09:38:04 -05:00
|
|
|
opacity: this.opacity(),
|
2022-01-14 14:14:13 -05:00
|
|
|
color: was ? this._wire.material.color : undefined,
|
2021-12-30 23:28:36 -05:00
|
|
|
} : {
|
|
|
|
|
enabled: false
|
|
|
|
|
};
|
|
|
|
|
}
|
2022-01-01 13:43:35 -05:00
|
|
|
if (bool.toggle) {
|
2022-01-14 14:14:13 -05:00
|
|
|
bool = !was;
|
2022-01-01 13:43:35 -05:00
|
|
|
}
|
2022-01-14 14:14:13 -05:00
|
|
|
// no change
|
2022-01-12 01:09:34 -05:00
|
|
|
if (was === bool) {
|
|
|
|
|
return was;
|
|
|
|
|
}
|
2022-01-14 14:14:13 -05:00
|
|
|
if (was) {
|
2021-12-30 23:23:45 -05:00
|
|
|
this.mesh.remove(this._wire);
|
|
|
|
|
this._wire = undefined;
|
2022-01-06 09:38:04 -05:00
|
|
|
this.opacity({restore: true});
|
2021-12-30 23:23:45 -05:00
|
|
|
}
|
2024-07-25 00:05:24 -04:00
|
|
|
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) {
|
2022-01-12 11:46:10 -05:00
|
|
|
this._wire = new Mesh(this.mesh.geometry.shallowClone(), materials.wireframe);
|
2021-12-30 23:23:45 -05:00
|
|
|
this.mesh.add(this._wire);
|
2022-01-13 11:59:26 -05:00
|
|
|
this.opacity({temp: opt.opacity || 0.15});
|
2021-12-30 23:23:45 -05:00
|
|
|
}
|
2022-01-02 14:25:28 -05:00
|
|
|
space.update();
|
2022-01-11 14:41:49 -05:00
|
|
|
return was;
|
2021-12-30 23:23:45 -05:00
|
|
|
}
|
|
|
|
|
|
2022-01-14 14:14:13 -05:00
|
|
|
normals(bool) {
|
|
|
|
|
let was = this._norm ? true : false;
|
|
|
|
|
if (bool === undefined) {
|
|
|
|
|
return was;
|
|
|
|
|
}
|
|
|
|
|
if (bool.toggle) {
|
|
|
|
|
bool = !was;
|
|
|
|
|
}
|
2022-01-15 22:24:54 -05:00
|
|
|
if (bool.refresh && !was) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2022-01-14 14:14:13 -05:00
|
|
|
// no change
|
|
|
|
|
if (was === bool) {
|
|
|
|
|
return was;
|
|
|
|
|
}
|
|
|
|
|
if (was) {
|
|
|
|
|
this.mesh.remove(this._norm);
|
|
|
|
|
this._norm = undefined;
|
|
|
|
|
}
|
|
|
|
|
if (bool) {
|
2022-02-25 13:56:09 -05:00
|
|
|
this.mesh.add(this._norm = mesh.util.faceNormals(this.mesh));
|
2022-01-14 14:14:13 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-09 21:53:33 -04:00
|
|
|
render() {
|
|
|
|
|
// TODO
|
|
|
|
|
}
|
|
|
|
|
|
2022-01-14 14:30:07 -05:00
|
|
|
// invert normals for entire mesh or selected faces depending on mod
|
|
|
|
|
invert(mode) {
|
|
|
|
|
let { modes } = mesh.api;
|
2022-01-14 16:45:40 -05:00
|
|
|
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;
|
|
|
|
|
}
|
2022-01-14 14:30:07 -05:00
|
|
|
switch (mode) {
|
|
|
|
|
case modes.object:
|
2022-01-14 16:45:40 -05:00
|
|
|
for (let i=0, l=arr.length; i<l; i += 9) {
|
|
|
|
|
swap(i);
|
|
|
|
|
}
|
2022-01-14 14:30:07 -05:00
|
|
|
break;
|
2022-04-02 21:25:03 -04:00
|
|
|
case modes.surface:
|
2022-01-14 14:30:07 -05:00
|
|
|
case modes.face:
|
2022-01-14 16:45:40 -05:00
|
|
|
for (let face of this.sel.faces) {
|
|
|
|
|
swap(face * 9);
|
|
|
|
|
}
|
2022-01-14 14:30:07 -05:00
|
|
|
break;
|
|
|
|
|
}
|
2022-01-14 16:45:40 -05:00
|
|
|
this.reload(arr);
|
|
|
|
|
if (this._norm) this._norm.update();
|
2022-01-14 14:30:07 -05:00
|
|
|
}
|
|
|
|
|
|
2022-01-15 15:04:53 -05:00
|
|
|
// split model along given plane
|
|
|
|
|
split(plane) {
|
|
|
|
|
// extract axes from plane and split when present (only z for now)
|
|
|
|
|
let { z } = plane;
|
2024-08-11 14:45:58 -04:00
|
|
|
let { id, group } = this;
|
2022-01-15 15:04:53 -05:00
|
|
|
return new Promise((resolve,reject) => {
|
2024-08-11 14:45:58 -04:00
|
|
|
worker.model_split({ id, z }).then(data => {
|
2022-01-15 15:04:53 -05:00
|
|
|
let { o1, o2 } = data;
|
2024-08-10 21:59:02 -04:00
|
|
|
if (!(o1 || o2)) {
|
2024-08-11 14:45:58 -04:00
|
|
|
return resolve(group);
|
2024-08-10 21:59:02 -04:00
|
|
|
}
|
2024-08-10 16:50:12 -04:00
|
|
|
if (o1?.length)
|
2024-08-11 14:45:58 -04:00
|
|
|
group.add(new mesh.model({
|
2024-08-10 21:59:02 -04:00
|
|
|
file: `${this.file}-bot`,
|
2024-08-10 11:40:24 -04:00
|
|
|
mesh: o1
|
|
|
|
|
}).reCenter());
|
2024-08-10 16:50:12 -04:00
|
|
|
if (o2?.length)
|
2024-08-11 14:45:58 -04:00
|
|
|
group.add(new mesh.model({
|
2024-08-10 21:59:02 -04:00
|
|
|
file: `${this.file}-top`,
|
2022-01-15 16:38:50 -05:00
|
|
|
mesh: o2
|
2024-08-10 11:40:24 -04:00
|
|
|
}).reCenter());
|
|
|
|
|
this.remove();
|
2024-08-11 14:45:58 -04:00
|
|
|
resolve(group);
|
2022-01-15 23:17:53 -05:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2022-01-20 19:21:26 -05:00
|
|
|
// release from a group but remain in memory and storage
|
|
|
|
|
// so it can be re-assigned to another group
|
|
|
|
|
ungroup() {
|
2024-07-20 02:24:40 -04:00
|
|
|
if (this.group) {
|
|
|
|
|
this.group.remove(this, { free: false });
|
|
|
|
|
this.group = undefined;
|
|
|
|
|
}
|
2024-08-09 21:53:33 -04:00
|
|
|
return this;
|
2022-01-20 19:21:26 -05:00
|
|
|
}
|
|
|
|
|
|
2022-01-14 14:30:07 -05:00
|
|
|
// remove model from group and space
|
2021-12-30 22:59:46 -05:00
|
|
|
remove() {
|
2022-01-02 00:40:43 -05:00
|
|
|
if (arguments.length === 0) {
|
|
|
|
|
// direct call requires pass through group
|
2022-01-03 17:13:50 -05:00
|
|
|
this.group.remove(this);
|
|
|
|
|
this.group = undefined;
|
|
|
|
|
this.removed = 'pending';
|
2022-01-02 00:40:43 -05:00
|
|
|
} else {
|
2022-01-09 21:05:14 -05:00
|
|
|
// manage lifecycle with worker, mesh app caches, etc
|
|
|
|
|
this.destroy();
|
2022-01-03 17:13:50 -05:00
|
|
|
// tag removed for debugging
|
|
|
|
|
this.removed = 'complete';
|
2022-01-02 00:40:43 -05:00
|
|
|
}
|
2021-12-30 22:59:46 -05:00
|
|
|
}
|
2022-01-08 22:42:57 -05:00
|
|
|
|
2024-08-12 15:54:49 -04:00
|
|
|
collectFacesByMaterialIndex(index) {
|
2022-01-13 20:23:48 -05:00
|
|
|
let { geometry } = this.mesh;
|
|
|
|
|
let { groups } = geometry;
|
|
|
|
|
let { array } = geometry.attributes.position;
|
|
|
|
|
let newtot = 0;
|
|
|
|
|
// filter to unselected groups
|
2024-08-12 15:54:49 -04:00
|
|
|
groups = groups.filter(g => g.materialIndex === index);
|
2022-01-13 20:23:48 -05:00
|
|
|
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;
|
|
|
|
|
}
|
2024-08-12 15:54:49 -04:00
|
|
|
return newverts;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
clearSelections() {
|
2022-01-13 20:23:48 -05:00
|
|
|
// clear face selections (since they've been deleted);
|
|
|
|
|
this.sel.faces = [];
|
|
|
|
|
this.updateSelections();
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-13 15:38:41 -04:00
|
|
|
selectionToRotationPolys() {
|
2024-08-12 15:54:49 -04:00
|
|
|
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
|
|
|
|
|
const targetNorm = new THREE.Vector3(0, 0, 1);
|
|
|
|
|
const rotato = new THREE.Quaternion().setFromUnitVectors(norm, targetNorm);
|
|
|
|
|
points.forEach(p => p.applyQuaternion(rotato));
|
|
|
|
|
// union and earcut the result
|
|
|
|
|
let polys = tris.map(t => newPolygon().fromVectors([ t.a, t.b, t.c ]));
|
|
|
|
|
let union = polygons.union(polys,0,true);
|
2024-08-13 15:38:41 -04:00
|
|
|
return { polys: union, normal: targetNorm, quaternion: rotato };
|
|
|
|
|
} else {
|
|
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
triangulateSelections() {
|
|
|
|
|
let { polys, quaternion } = this.selectionToRotationPolys();
|
|
|
|
|
if (polys) {
|
|
|
|
|
let ears = polys.map(p => p.earcut()).flat();
|
2024-08-12 15:54:49 -04:00
|
|
|
let nupoints = ears.map(p => p.points).flat().map(p => p.toVector3());
|
2024-08-13 15:38:41 -04:00
|
|
|
// invert quaternion to restore points alignment to original face
|
|
|
|
|
nupoints.forEach(p => p.applyQuaternion(quaternion.invert()));
|
2024-08-12 15:54:49 -04:00
|
|
|
let nuverts = nupoints.map(p => p.toArray()).flat().toFloat32();
|
|
|
|
|
// remove selection and append nuverts
|
|
|
|
|
this.deleteSelections(nuverts);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
deleteSelections(append) {
|
|
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2022-01-13 20:23:48 -05:00
|
|
|
updateSelections() {
|
2022-04-04 21:37:15 -04:00
|
|
|
let groups = mesh.util.facesToGroups(this.sel.faces);
|
2022-01-13 12:59:18 -05:00
|
|
|
let geo = this.mesh.geometry;
|
|
|
|
|
geo.clearGroups();
|
|
|
|
|
for (let group of groups) {
|
|
|
|
|
geo.addGroup(group.start*3, group.count*3, group.mat || 0);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2022-01-20 17:19:15 -05:00
|
|
|
selectFaces(list = [], action = {}) {
|
2022-01-13 20:23:48 -05:00
|
|
|
let faces = this.sel.faces;
|
2022-04-14 12:46:31 -04:00
|
|
|
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;
|
2022-01-20 17:19:15 -05:00
|
|
|
}
|
2022-04-14 12:46:31 -04:00
|
|
|
faces.appendAll(Object.values(map).filter(f => f !== undefined));
|
2022-01-13 12:59:18 -05:00
|
|
|
}
|
2022-01-13 20:23:48 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
};
|
|
|
|
|
}
|
2022-01-08 22:42:57 -05:00
|
|
|
}
|
|
|
|
|
|
2022-01-12 23:02:08 -05:00
|
|
|
// find adjacent faces to clicked point/line on a face
|
2022-04-04 19:17:13 -04:00
|
|
|
find(int, action, surface) {
|
2022-01-20 17:19:15 -05:00
|
|
|
let { point, face } = int;
|
2022-01-12 23:02:08 -05:00
|
|
|
let { x, y, z } = point;
|
2022-01-12 23:19:24 -05:00
|
|
|
let { a, b, c } = face;
|
2022-03-31 15:53:33 -04:00
|
|
|
let timer = setTimeout(() => {
|
|
|
|
|
timer = undefined;
|
2022-03-31 20:45:49 -04:00
|
|
|
mesh.api.log.emit("matching surface").pin();
|
2022-04-01 09:45:35 -04:00
|
|
|
}, 150);
|
2022-04-14 12:46:31 -04:00
|
|
|
// let mark = Date.now();
|
2022-01-12 23:19:24 -05:00
|
|
|
worker.model_select({
|
2024-08-10 11:40:24 -04:00
|
|
|
id: this.id, x, y:-z, z:y, a, b, c, surface
|
2022-01-12 23:02:08 -05:00
|
|
|
}).then(data => {
|
2022-04-14 12:46:31 -04:00
|
|
|
// mesh.api.log.emit(`... data time = ${Date.now() - mark}`); mark = Date.now();
|
2022-03-31 15:53:33 -04:00
|
|
|
if (timer) {
|
|
|
|
|
clearTimeout(timer);
|
|
|
|
|
}
|
2022-01-13 20:23:48 -05:00
|
|
|
let { faces, edges, verts, point } = data;
|
2022-01-13 21:17:35 -05:00
|
|
|
// console.log({data});
|
2022-01-13 20:23:48 -05:00
|
|
|
// this.toggleSelectedVertices(verts);
|
2022-01-20 17:19:15 -05:00
|
|
|
this.selectFaces(faces, action);
|
2022-04-14 12:46:31 -04:00
|
|
|
// mesh.api.log.emit(`... select time = ${Date.now() - mark}`); mark = Date.now();
|
2022-01-13 20:23:48 -05:00
|
|
|
this.updateSelections();
|
2022-03-31 20:45:49 -04:00
|
|
|
if (!timer) {
|
|
|
|
|
mesh.api.log.emit("surface match complete").unpin();
|
|
|
|
|
moto.space.refresh();
|
|
|
|
|
}
|
2022-04-14 12:46:31 -04:00
|
|
|
// mesh.api.log.emit(`... paint time = ${Date.now() - mark}`);
|
2022-01-12 23:02:08 -05:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2021-12-29 16:49:15 -05:00
|
|
|
};
|
|
|
|
|
|
2022-02-20 12:56:43 -05:00
|
|
|
});
|