prophetk-3d-viewer
Version:
A Three.js r128 based GLB/GLTF model viewer for WeChat Mini Program and Web.
155 lines (143 loc) • 5.87 kB
JavaScript
const traverseModel = require('./modelTraversal.js');
function parentId(value) {
if (value === undefined || value === null) return null;
if (typeof value !== 'string' || !value.trim()) throw new Error('parent must be a non-empty model ID or null');
return value;
}
class ModelHierarchy {
constructor(renderer) {
this.renderer = renderer;
this.records = new Map();
Object.keys(renderer._modelNodes).forEach(id => {
traverseModel.registerRoot(renderer._modelNodes[id]);
this.records.set(id, { id, parent: null, object: renderer._modelNodes[id], state: 'ready' });
});
}
static validate(items) {
const graph = new Map();
items.forEach((item, index) => {
const id = item.id || item.name || ('model_' + index);
if (graph.has(id)) throw new Error('Duplicate model ID: ' + id);
graph.set(id, { parent: parentId(item.parent) });
});
graph.forEach((record, id) => ModelHierarchy.checkCycle(graph, id, record.parent));
}
static checkCycle(records, id, parent) {
const seen = new Set([id]);
for (let next = parent; next !== null && next !== undefined;) {
if (seen.has(next)) throw new Error('Cyclic model parent: ' + id);
seen.add(next);
const record = records.get(next);
next = record ? record.parent : null;
}
}
load(item, id, normalize) {
const r = this.renderer;
const parent = parentId(item.parent);
if (r._disposed) throw new Error('renderer disposed');
const previous = this.records.get(id);
if (previous && previous.state !== 'failed') throw new Error('Duplicate model ID: ' + id);
ModelHierarchy.checkCycle(this.records, id, parent);
const record = { id, parent, url: item.url, invisible: item.invisible === true, state: 'loading', object: null };
this.records.set(id, record);
return new Promise((resolve, reject) => {
record.reject = reject;
Promise.resolve().then(() => r._loadGltfObject(item.url, { normalize })).then(object => {
if (this.records.get(id) !== record || r._disposed) {
r._disposeObject(object);
return;
}
r._applyTransform(object, item);
object.name = id;
object.userData.modelId = id;
object.userData.modelUrl = item.url;
record.object = object;
record.state = 'ready';
r._modelNodes[id] = object;
traverseModel.registerRoot(object);
// Register before attaching descendants so geometry belongs to one model.
r._interaction.register(id, object, item.isMovable, item.clickEvent);
this.sync();
record.reject = null;
resolve(object);
}).catch(error => {
if (this.records.get(id) === record) record.state = 'failed';
record.reject = null;
reject(error);
});
});
}
sync() {
const r = this.renderer;
const ready = new Map();
const connected = record => {
if (ready.has(record.id)) return ready.get(record.id);
const parent = this.records.get(record.parent);
const value = record.state === 'ready' && (record.parent === null || !!parent && connected(parent));
ready.set(record.id, value);
return value;
};
this.records.forEach(record => {
if (!record.object) return;
const target = connected(record)
? (record.parent === null ? r._sceneRoot : this.records.get(record.parent).object) : null;
if (record.object.parent !== target) {
if (target) target.add(record.object);
else if (record.object.parent) record.object.parent.remove(record.object);
}
});
const selected = this.records.get(r._interaction.selectedId);
if (selected && !connected(selected)) r._interaction.select(null, 'parentchange');
r._interaction.updateHelper();
r._requestRender();
}
descendants(ids) {
const result = new Set(ids);
let changed = true;
while (changed) {
changed = false;
this.records.forEach(record => {
if (result.has(record.parent) && !result.has(record.id)) {
result.add(record.id);
changed = true;
}
});
}
return result;
}
setParent(id, value) {
const record = this.records.get(id);
if (!record) throw new Error('Unknown model ID: ' + id);
const parent = parentId(value);
ModelHierarchy.checkCycle(this.records, id, parent);
if (record.parent === parent) return;
this.cancelAffected(this.descendants([id]), 'parentchange');
if (this.records.get(id) !== record) return; // A cancellation callback may remove this model.
ModelHierarchy.checkCycle(this.records, id, parent);
record.parent = parent;
this.sync();
}
cancelAffected(ids, reason) {
const interaction = this.renderer._interaction;
const g = interaction.gesture;
if (g && [g.entry, g.clickEntry].some(entry => entry && ids.has(entry.id))) interaction.cancel(reason);
}
remove(ids, reason = 'modelremoved') {
const r = this.renderer;
const affected = this.descendants(ids);
this.cancelAffected(affected, reason);
const records = Array.from(affected).map(id => this.records.get(id)).filter(Boolean);
// Invalidate every record before callbacks or asynchronous results can run.
records.forEach(record => { this.records.delete(record.id); delete r._modelNodes[record.id]; });
records.forEach(record => {
r._interaction.remove(record.id, reason);
if (record.reject) record.reject(new Error('Model load cancelled: ' + record.id));
if (record.object && record.object.parent) record.object.parent.remove(record.object);
});
records.forEach(record => { if (record.object) r._clearModelObject(record.object); });
r._refreshCurrentModel();
if (records.length) r._requestRender();
return records.length;
}
}
module.exports = ModelHierarchy;