Add support for 3d models in assets and attachments

This commit is contained in:
2026-01-13 13:46:28 +01:00
parent e3d3a62f84
commit f215ef9aba
20 changed files with 15057 additions and 24 deletions

View File

@@ -1,5 +1,6 @@
import { isAudioAsset } from "../media/audio.js";
import { isCodeAsset, isGifAsset, isVideoAsset, isVideoElement } from "../broadcast/assetKinds.js";
import { isCodeAsset, isGifAsset, isModelAsset, isVideoAsset, isVideoElement } from "../broadcast/assetKinds.js";
import { createModelManager } from "../media/modelManager.js";
import {
ensureLayerPosition as ensureLayerPositionForState,
getLayerOrder as getLayerOrderForState,
@@ -75,6 +76,7 @@ export function createAdminConsole({
const aspectLockState = new Map();
const commitSizeChange = debounce(() => applyTransformFromInputs(), 180);
const audioUnlockEvents = ["pointerdown", "keydown", "touchstart"];
const modelManager = createModelManager({ requestDraw: () => requestDraw() });
let drawPending = false;
let layerOrder = [];
@@ -693,7 +695,11 @@ export function createAdminConsole({
let drawSource = null;
let ready = false;
let showPlayOverlay = false;
if (isVideoAsset(asset) || isGifAsset(asset)) {
if (isModelAsset(asset)) {
const model = modelManager.ensureModel(asset);
drawSource = model?.canvas || null;
ready = !!model?.ready;
} else if (isVideoAsset(asset) || isGifAsset(asset)) {
drawSource = ensureCanvasPreview(asset);
ready = isDrawable(drawSource);
showPlayOverlay = true;
@@ -978,6 +984,7 @@ export function createAdminConsole({
IMAGE: "Image",
VIDEO: "Video",
AUDIO: "Audio",
MODEL: "3D Model",
SCRIPT: "Script",
OTHER: "Other",
};
@@ -1020,6 +1027,7 @@ export function createAdminConsole({
function clearMedia(assetId) {
mediaCache.delete(assetId);
modelManager.clearModel(assetId);
const cachedPreview = previewCache.get(assetId);
if (cachedPreview && cachedPreview.startsWith("blob:")) {
URL.revokeObjectURL(cachedPreview);
@@ -1143,6 +1151,10 @@ export function createAdminConsole({
return null;
}
if (isModelAsset(asset)) {
return null;
}
if (isVideoAsset(asset)) {
return null;
}

View File

@@ -16,6 +16,13 @@ export function isVideoAsset(asset) {
return asset?.mediaType?.startsWith("video/");
}
export function isModelAsset(asset) {
if (asset?.assetType) {
return asset.assetType === "MODEL";
}
return asset?.mediaType?.startsWith("model/");
}
export function isVideoElement(element) {
return element?.tagName === "VIDEO";
}

View File

@@ -1,10 +1,11 @@
import { AssetKind, MIN_FRAME_TIME, VISIBILITY_THRESHOLD } from "./constants.js";
import { createBroadcastState } from "./state.js";
import { getAssetKind, isCodeAsset, isVisualAsset, isVideoElement } from "./assetKinds.js";
import { getAssetKind, isCodeAsset, isModelAsset, isVisualAsset, isVideoElement } from "./assetKinds.js";
import { ensureLayerPosition, getLayerOrder, getRenderOrder } from "./layers.js";
import { getVisibilityState, smoothState } from "./visibility.js";
import { createAudioManager } from "./audioManager.js";
import { createMediaManager } from "./mediaManager.js";
import { createModelManager } from "../media/modelManager.js";
export class BroadcastRenderer {
constructor({ canvas, scriptCanvas, broadcaster, showToast }) {
@@ -37,6 +38,7 @@ export class BroadcastRenderer {
supportsAnimatedDecode: this.supportsAnimatedDecode,
canPlayProbe: this.canPlayProbe,
});
this.modelManager = createModelManager({ requestDraw: () => this.draw() });
this.applyCanvasSettings(this.state.canvasSettings);
globalThis.addEventListener("resize", () => {
@@ -105,6 +107,7 @@ export class BroadcastRenderer {
this.state.assets.delete(assetId);
this.state.layerOrder = this.state.layerOrder.filter((id) => id !== assetId);
this.mediaManager.clearMedia(assetId);
this.modelManager.clearModel(assetId);
this.stopUserJavaScriptWorker(assetId);
this.state.renderStates.delete(assetId);
this.state.visibilityStates.delete(assetId);
@@ -348,9 +351,17 @@ export class BroadcastRenderer {
return;
}
const media = this.mediaManager.ensureMedia(asset);
const drawSource = media?.isAnimated ? media.bitmap : media;
const ready = this.isDrawable(media);
let drawSource = null;
let ready = false;
if (isModelAsset(asset)) {
const model = this.modelManager.ensureModel(asset);
drawSource = model?.canvas || null;
ready = !!model?.ready;
} else {
const media = this.mediaManager.ensureMedia(asset);
drawSource = media?.isAnimated ? media.bitmap : media;
ready = this.isDrawable(media);
}
if (ready && drawSource) {
this.ctx.drawImage(drawSource, -halfWidth, -halfHeight, renderState.width, renderState.height);
}

View File

@@ -8,6 +8,37 @@ let lastTick = 0;
let startTime = 0;
const tickIntervalMs = 1000 / 60;
const errorKeys = new Set();
const allowedImportUrls = new Set();
const nativeImportScripts = typeof self.importScripts === "function" ? self.importScripts.bind(self) : null;
const sharedDependencyUrls = ["/js/vendor/three.min.js", "/js/vendor/GLTFLoader.js", "/js/vendor/OBJLoader.js"];
function normalizeUrl(url) {
try {
return new URL(url, self.location?.href || "http://localhost").toString();
} catch (_error) {
return "";
}
}
function registerAllowedImport(url) {
const normalized = normalizeUrl(url);
if (normalized) {
allowedImportUrls.add(normalized);
}
}
sharedDependencyUrls.forEach(registerAllowedImport);
function importAllowedScripts(...urls) {
if (!nativeImportScripts) {
throw new Error("Network access is disabled in asset scripts.");
}
const resolved = urls.map((url) => normalizeUrl(url));
if (resolved.some((url) => !allowedImportUrls.has(url))) {
throw new Error("Network access is disabled in asset scripts.");
}
return nativeImportScripts(...resolved);
}
function disableNetworkApis() {
const nativeFetch = typeof self.fetch === "function" ? self.fetch.bind(self) : null;
@@ -26,9 +57,7 @@ function disableNetworkApis() {
XMLHttpRequest: undefined,
WebSocket: undefined,
EventSource: undefined,
importScripts: () => {
throw new Error("Network access is disabled in asset scripts.");
},
importScripts: (...urls) => importAllowedScripts(...urls),
};
Object.entries(blockedApis).forEach(([key, value]) => {
@@ -53,14 +82,15 @@ function disableNetworkApis() {
disableNetworkApis();
function normalizeUrl(url) {
try {
return new URL(url, self.location?.href || "http://localhost").toString();
} catch (_error) {
return "";
function loadSharedDependencies() {
if (!nativeImportScripts || sharedDependencyUrls.length === 0) {
return;
}
importAllowedScripts(...sharedDependencyUrls);
}
loadSharedDependencies();
function refreshAllowedFetchUrls() {
allowedFetchUrls.clear();
scripts.forEach((script) => {

View File

@@ -0,0 +1,170 @@
const DEFAULT_WIDTH = 640;
const DEFAULT_HEIGHT = 360;
const MAX_PIXEL_RATIO = 2;
function getThree() {
return globalThis.THREE || null;
}
function clampSize(value, fallback) {
if (!Number.isFinite(value) || value <= 0) {
return fallback;
}
return Math.max(1, Math.round(value));
}
function pickLoader(asset, three) {
const url = (asset?.url || "").toLowerCase();
if ((asset?.mediaType === "model/obj" || url.endsWith(".obj")) && typeof three.OBJLoader === "function") {
return { loader: new three.OBJLoader(), kind: "obj" };
}
if (typeof three.GLTFLoader === "function") {
return { loader: new three.GLTFLoader(), kind: "gltf" };
}
return null;
}
function centerAndScale(model, three) {
const box = new three.Box3().setFromObject(model);
const size = box.getSize(new three.Vector3());
const center = box.getCenter(new three.Vector3());
model.position.sub(center);
const maxDim = Math.max(size.x, size.y, size.z) || 1;
const scale = 1.5 / maxDim;
model.scale.setScalar(scale);
return { size, maxDim };
}
function disposeModel(model) {
if (!model?.traverse) {
return;
}
model.traverse((child) => {
if (child.geometry?.dispose) {
child.geometry.dispose();
}
if (child.material) {
if (Array.isArray(child.material)) {
child.material.forEach((material) => material?.dispose?.());
} else {
child.material.dispose?.();
}
}
});
}
export function createModelManager({ requestDraw } = {}) {
const controllers = new Map();
function clearModel(assetId) {
const controller = controllers.get(assetId);
if (!controller) {
return;
}
if (controller.model) {
disposeModel(controller.model);
}
controller.renderer?.dispose?.();
controllers.delete(assetId);
}
function ensureModel(asset) {
const three = getThree();
if (!three || !asset?.id || !asset?.url) {
return null;
}
let controller = controllers.get(asset.id);
if (controller && controller.url !== asset.url) {
clearModel(asset.id);
controller = null;
}
if (!controller) {
const canvas = document.createElement("canvas");
const renderer = new three.WebGLRenderer({ canvas, alpha: true, antialias: true });
renderer.setPixelRatio(Math.min(globalThis.devicePixelRatio || 1, MAX_PIXEL_RATIO));
renderer.setClearColor(0x000000, 0);
const scene = new three.Scene();
const camera = new three.PerspectiveCamera(35, 1, 0.1, 100);
const ambient = new three.AmbientLight(0xffffff, 0.85);
const directional = new three.DirectionalLight(0xffffff, 0.65);
directional.position.set(1, 1, 1);
scene.add(ambient);
scene.add(directional);
controller = {
id: asset.id,
url: asset.url,
canvas,
renderer,
scene,
camera,
model: null,
ready: false,
startTime: performance.now(),
width: 0,
height: 0,
};
const loaderChoice = pickLoader(asset, three);
if (loaderChoice) {
if (loaderChoice.kind === "obj") {
loaderChoice.loader.load(asset.url, (obj) => {
const { maxDim } = centerAndScale(obj, three);
controller.model = obj;
controller.scene.add(obj);
const distance = maxDim * 2.2;
controller.camera.position.set(0, 0, distance);
controller.camera.near = Math.max(0.01, distance / 100);
controller.camera.far = distance * 100;
controller.camera.updateProjectionMatrix();
controller.ready = true;
requestDraw?.();
});
} else {
loaderChoice.loader.load(asset.url, (gltf) => {
const model = gltf.scene || gltf.scenes?.[0];
if (!model) {
return;
}
const { maxDim } = centerAndScale(model, three);
controller.model = model;
controller.scene.add(model);
const distance = maxDim * 2.2;
controller.camera.position.set(0, 0, distance);
controller.camera.near = Math.max(0.01, distance / 100);
controller.camera.far = distance * 100;
controller.camera.updateProjectionMatrix();
controller.ready = true;
requestDraw?.();
});
}
}
controllers.set(asset.id, controller);
}
const width = clampSize(asset.width, DEFAULT_WIDTH);
const height = clampSize(asset.height, DEFAULT_HEIGHT);
if (controller.width !== width || controller.height !== height) {
controller.width = width;
controller.height = height;
controller.renderer.setSize(width, height, false);
controller.camera.aspect = width / height;
controller.camera.updateProjectionMatrix();
}
if (controller.ready && controller.model) {
const now = performance.now();
controller.model.rotation.y = (now - controller.startTime) * 0.0004;
controller.renderer.render(controller.scene, controller.camera);
}
return { canvas: controller.canvas, ready: controller.ready };
}
return { ensureModel, clearModel };
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,789 @@
( function () {
// o object_name | g group_name
const _object_pattern = /^[og]\s*(.+)?/;
// mtllib file_reference
const _material_library_pattern = /^mtllib /;
// usemtl material_name
const _material_use_pattern = /^usemtl /;
// usemap map_name
const _map_use_pattern = /^usemap /;
const _face_vertex_data_separator_pattern = /\s+/;
const _vA = new THREE.Vector3();
const _vB = new THREE.Vector3();
const _vC = new THREE.Vector3();
const _ab = new THREE.Vector3();
const _cb = new THREE.Vector3();
const _color = new THREE.Color();
function ParserState() {
const state = {
objects: [],
object: {},
vertices: [],
normals: [],
colors: [],
uvs: [],
materials: {},
materialLibraries: [],
startObject: function ( name, fromDeclaration ) {
// If the current object (initial from reset) is not from a g/o declaration in the parsed
// file. We need to use it for the first parsed g/o to keep things in sync.
if ( this.object && this.object.fromDeclaration === false ) {
this.object.name = name;
this.object.fromDeclaration = fromDeclaration !== false;
return;
}
const previousMaterial = this.object && typeof this.object.currentMaterial === 'function' ? this.object.currentMaterial() : undefined;
if ( this.object && typeof this.object._finalize === 'function' ) {
this.object._finalize( true );
}
this.object = {
name: name || '',
fromDeclaration: fromDeclaration !== false,
geometry: {
vertices: [],
normals: [],
colors: [],
uvs: [],
hasUVIndices: false
},
materials: [],
smooth: true,
startMaterial: function ( name, libraries ) {
const previous = this._finalize( false );
// New usemtl declaration overwrites an inherited material, except if faces were declared
// after the material, then it must be preserved for proper MultiMaterial continuation.
if ( previous && ( previous.inherited || previous.groupCount <= 0 ) ) {
this.materials.splice( previous.index, 1 );
}
const material = {
index: this.materials.length,
name: name || '',
mtllib: Array.isArray( libraries ) && libraries.length > 0 ? libraries[ libraries.length - 1 ] : '',
smooth: previous !== undefined ? previous.smooth : this.smooth,
groupStart: previous !== undefined ? previous.groupEnd : 0,
groupEnd: - 1,
groupCount: - 1,
inherited: false,
clone: function ( index ) {
const cloned = {
index: typeof index === 'number' ? index : this.index,
name: this.name,
mtllib: this.mtllib,
smooth: this.smooth,
groupStart: 0,
groupEnd: - 1,
groupCount: - 1,
inherited: false
};
cloned.clone = this.clone.bind( cloned );
return cloned;
}
};
this.materials.push( material );
return material;
},
currentMaterial: function () {
if ( this.materials.length > 0 ) {
return this.materials[ this.materials.length - 1 ];
}
return undefined;
},
_finalize: function ( end ) {
const lastMultiMaterial = this.currentMaterial();
if ( lastMultiMaterial && lastMultiMaterial.groupEnd === - 1 ) {
lastMultiMaterial.groupEnd = this.geometry.vertices.length / 3;
lastMultiMaterial.groupCount = lastMultiMaterial.groupEnd - lastMultiMaterial.groupStart;
lastMultiMaterial.inherited = false;
}
// Ignore objects tail materials if no face declarations followed them before a new o/g started.
if ( end && this.materials.length > 1 ) {
for ( let mi = this.materials.length - 1; mi >= 0; mi -- ) {
if ( this.materials[ mi ].groupCount <= 0 ) {
this.materials.splice( mi, 1 );
}
}
}
// Guarantee at least one empty material, this makes the creation later more straight forward.
if ( end && this.materials.length === 0 ) {
this.materials.push( {
name: '',
smooth: this.smooth
} );
}
return lastMultiMaterial;
}
};
// Inherit previous objects material.
// Spec tells us that a declared material must be set to all objects until a new material is declared.
// If a usemtl declaration is encountered while this new object is being parsed, it will
// overwrite the inherited material. Exception being that there was already face declarations
// to the inherited material, then it will be preserved for proper MultiMaterial continuation.
if ( previousMaterial && previousMaterial.name && typeof previousMaterial.clone === 'function' ) {
const declared = previousMaterial.clone( 0 );
declared.inherited = true;
this.object.materials.push( declared );
}
this.objects.push( this.object );
},
finalize: function () {
if ( this.object && typeof this.object._finalize === 'function' ) {
this.object._finalize( true );
}
},
parseVertexIndex: function ( value, len ) {
const index = parseInt( value, 10 );
return ( index >= 0 ? index - 1 : index + len / 3 ) * 3;
},
parseNormalIndex: function ( value, len ) {
const index = parseInt( value, 10 );
return ( index >= 0 ? index - 1 : index + len / 3 ) * 3;
},
parseUVIndex: function ( value, len ) {
const index = parseInt( value, 10 );
return ( index >= 0 ? index - 1 : index + len / 2 ) * 2;
},
addVertex: function ( a, b, c ) {
const src = this.vertices;
const dst = this.object.geometry.vertices;
dst.push( src[ a + 0 ], src[ a + 1 ], src[ a + 2 ] );
dst.push( src[ b + 0 ], src[ b + 1 ], src[ b + 2 ] );
dst.push( src[ c + 0 ], src[ c + 1 ], src[ c + 2 ] );
},
addVertexPoint: function ( a ) {
const src = this.vertices;
const dst = this.object.geometry.vertices;
dst.push( src[ a + 0 ], src[ a + 1 ], src[ a + 2 ] );
},
addVertexLine: function ( a ) {
const src = this.vertices;
const dst = this.object.geometry.vertices;
dst.push( src[ a + 0 ], src[ a + 1 ], src[ a + 2 ] );
},
addNormal: function ( a, b, c ) {
const src = this.normals;
const dst = this.object.geometry.normals;
dst.push( src[ a + 0 ], src[ a + 1 ], src[ a + 2 ] );
dst.push( src[ b + 0 ], src[ b + 1 ], src[ b + 2 ] );
dst.push( src[ c + 0 ], src[ c + 1 ], src[ c + 2 ] );
},
addFaceNormal: function ( a, b, c ) {
const src = this.vertices;
const dst = this.object.geometry.normals;
_vA.fromArray( src, a );
_vB.fromArray( src, b );
_vC.fromArray( src, c );
_cb.subVectors( _vC, _vB );
_ab.subVectors( _vA, _vB );
_cb.cross( _ab );
_cb.normalize();
dst.push( _cb.x, _cb.y, _cb.z );
dst.push( _cb.x, _cb.y, _cb.z );
dst.push( _cb.x, _cb.y, _cb.z );
},
addColor: function ( a, b, c ) {
const src = this.colors;
const dst = this.object.geometry.colors;
if ( src[ a ] !== undefined ) dst.push( src[ a + 0 ], src[ a + 1 ], src[ a + 2 ] );
if ( src[ b ] !== undefined ) dst.push( src[ b + 0 ], src[ b + 1 ], src[ b + 2 ] );
if ( src[ c ] !== undefined ) dst.push( src[ c + 0 ], src[ c + 1 ], src[ c + 2 ] );
},
addUV: function ( a, b, c ) {
const src = this.uvs;
const dst = this.object.geometry.uvs;
dst.push( src[ a + 0 ], src[ a + 1 ] );
dst.push( src[ b + 0 ], src[ b + 1 ] );
dst.push( src[ c + 0 ], src[ c + 1 ] );
},
addDefaultUV: function () {
const dst = this.object.geometry.uvs;
dst.push( 0, 0 );
dst.push( 0, 0 );
dst.push( 0, 0 );
},
addUVLine: function ( a ) {
const src = this.uvs;
const dst = this.object.geometry.uvs;
dst.push( src[ a + 0 ], src[ a + 1 ] );
},
addFace: function ( a, b, c, ua, ub, uc, na, nb, nc ) {
const vLen = this.vertices.length;
let ia = this.parseVertexIndex( a, vLen );
let ib = this.parseVertexIndex( b, vLen );
let ic = this.parseVertexIndex( c, vLen );
this.addVertex( ia, ib, ic );
this.addColor( ia, ib, ic );
// normals
if ( na !== undefined && na !== '' ) {
const nLen = this.normals.length;
ia = this.parseNormalIndex( na, nLen );
ib = this.parseNormalIndex( nb, nLen );
ic = this.parseNormalIndex( nc, nLen );
this.addNormal( ia, ib, ic );
} else {
this.addFaceNormal( ia, ib, ic );
}
// uvs
if ( ua !== undefined && ua !== '' ) {
const uvLen = this.uvs.length;
ia = this.parseUVIndex( ua, uvLen );
ib = this.parseUVIndex( ub, uvLen );
ic = this.parseUVIndex( uc, uvLen );
this.addUV( ia, ib, ic );
this.object.geometry.hasUVIndices = true;
} else {
// add placeholder values (for inconsistent face definitions)
this.addDefaultUV();
}
},
addPointGeometry: function ( vertices ) {
this.object.geometry.type = 'Points';
const vLen = this.vertices.length;
for ( let vi = 0, l = vertices.length; vi < l; vi ++ ) {
const index = this.parseVertexIndex( vertices[ vi ], vLen );
this.addVertexPoint( index );
this.addColor( index );
}
},
addLineGeometry: function ( vertices, uvs ) {
this.object.geometry.type = 'Line';
const vLen = this.vertices.length;
const uvLen = this.uvs.length;
for ( let vi = 0, l = vertices.length; vi < l; vi ++ ) {
this.addVertexLine( this.parseVertexIndex( vertices[ vi ], vLen ) );
}
for ( let uvi = 0, l = uvs.length; uvi < l; uvi ++ ) {
this.addUVLine( this.parseUVIndex( uvs[ uvi ], uvLen ) );
}
}
};
state.startObject( '', false );
return state;
}
//
class OBJLoader extends THREE.Loader {
constructor( manager ) {
super( manager );
this.materials = null;
}
load( url, onLoad, onProgress, onError ) {
const scope = this;
const loader = new THREE.FileLoader( this.manager );
loader.setPath( this.path );
loader.setRequestHeader( this.requestHeader );
loader.setWithCredentials( this.withCredentials );
loader.load( url, function ( text ) {
try {
onLoad( scope.parse( text ) );
} catch ( e ) {
if ( onError ) {
onError( e );
} else {
console.error( e );
}
scope.manager.itemError( url );
}
}, onProgress, onError );
}
setMaterials( materials ) {
this.materials = materials;
return this;
}
parse( text ) {
const state = new ParserState();
if ( text.indexOf( '\r\n' ) !== - 1 ) {
// This is faster than String.split with regex that splits on both
text = text.replace( /\r\n/g, '\n' );
}
if ( text.indexOf( '\\\n' ) !== - 1 ) {
// join lines separated by a line continuation character (\)
text = text.replace( /\\\n/g, '' );
}
const lines = text.split( '\n' );
let result = [];
for ( let i = 0, l = lines.length; i < l; i ++ ) {
const line = lines[ i ].trimStart();
if ( line.length === 0 ) continue;
const lineFirstChar = line.charAt( 0 );
// @todo invoke passed in handler if any
if ( lineFirstChar === '#' ) continue;
if ( lineFirstChar === 'v' ) {
const data = line.split( _face_vertex_data_separator_pattern );
switch ( data[ 0 ] ) {
case 'v':
state.vertices.push( parseFloat( data[ 1 ] ), parseFloat( data[ 2 ] ), parseFloat( data[ 3 ] ) );
if ( data.length >= 7 ) {
_color.setRGB( parseFloat( data[ 4 ] ), parseFloat( data[ 5 ] ), parseFloat( data[ 6 ] ) ).convertSRGBToLinear();
state.colors.push( _color.r, _color.g, _color.b );
} else {
// if no colors are defined, add placeholders so color and vertex indices match
state.colors.push( undefined, undefined, undefined );
}
break;
case 'vn':
state.normals.push( parseFloat( data[ 1 ] ), parseFloat( data[ 2 ] ), parseFloat( data[ 3 ] ) );
break;
case 'vt':
state.uvs.push( parseFloat( data[ 1 ] ), parseFloat( data[ 2 ] ) );
break;
}
} else if ( lineFirstChar === 'f' ) {
const lineData = line.slice( 1 ).trim();
const vertexData = lineData.split( _face_vertex_data_separator_pattern );
const faceVertices = [];
// Parse the face vertex data into an easy to work with format
for ( let j = 0, jl = vertexData.length; j < jl; j ++ ) {
const vertex = vertexData[ j ];
if ( vertex.length > 0 ) {
const vertexParts = vertex.split( '/' );
faceVertices.push( vertexParts );
}
}
// Draw an edge between the first vertex and all subsequent vertices to form an n-gon
const v1 = faceVertices[ 0 ];
for ( let j = 1, jl = faceVertices.length - 1; j < jl; j ++ ) {
const v2 = faceVertices[ j ];
const v3 = faceVertices[ j + 1 ];
state.addFace( v1[ 0 ], v2[ 0 ], v3[ 0 ], v1[ 1 ], v2[ 1 ], v3[ 1 ], v1[ 2 ], v2[ 2 ], v3[ 2 ] );
}
} else if ( lineFirstChar === 'l' ) {
const lineParts = line.substring( 1 ).trim().split( ' ' );
let lineVertices = [];
const lineUVs = [];
if ( line.indexOf( '/' ) === - 1 ) {
lineVertices = lineParts;
} else {
for ( let li = 0, llen = lineParts.length; li < llen; li ++ ) {
const parts = lineParts[ li ].split( '/' );
if ( parts[ 0 ] !== '' ) lineVertices.push( parts[ 0 ] );
if ( parts[ 1 ] !== '' ) lineUVs.push( parts[ 1 ] );
}
}
state.addLineGeometry( lineVertices, lineUVs );
} else if ( lineFirstChar === 'p' ) {
const lineData = line.slice( 1 ).trim();
const pointData = lineData.split( ' ' );
state.addPointGeometry( pointData );
} else if ( ( result = _object_pattern.exec( line ) ) !== null ) {
// o object_name
// or
// g group_name
// WORKAROUND: https://bugs.chromium.org/p/v8/issues/detail?id=2869
// let name = result[ 0 ].slice( 1 ).trim();
const name = ( ' ' + result[ 0 ].slice( 1 ).trim() ).slice( 1 );
state.startObject( name );
} else if ( _material_use_pattern.test( line ) ) {
// material
state.object.startMaterial( line.substring( 7 ).trim(), state.materialLibraries );
} else if ( _material_library_pattern.test( line ) ) {
// mtl file
state.materialLibraries.push( line.substring( 7 ).trim() );
} else if ( _map_use_pattern.test( line ) ) {
// the line is parsed but ignored since the loader assumes textures are defined MTL files
// (according to https://www.okino.com/conv/imp_wave.htm, 'usemap' is the old-style Wavefront texture reference method)
console.warn( 'THREE.OBJLoader: Rendering identifier "usemap" not supported. Textures must be defined in MTL files.' );
} else if ( lineFirstChar === 's' ) {
result = line.split( ' ' );
// smooth shading
// @todo Handle files that have varying smooth values for a set of faces inside one geometry,
// but does not define a usemtl for each face set.
// This should be detected and a dummy material created (later MultiMaterial and geometry groups).
// This requires some care to not create extra material on each smooth value for "normal" obj files.
// where explicit usemtl defines geometry groups.
// Example asset: examples/models/obj/cerberus/Cerberus.obj
/*
* http://paulbourke.net/dataformats/obj/
*
* From chapter "Grouping" Syntax explanation "s group_number":
* "group_number is the smoothing group number. To turn off smoothing groups, use a value of 0 or off.
* Polygonal elements use group numbers to put elements in different smoothing groups. For free-form
* surfaces, smoothing groups are either turned on or off; there is no difference between values greater
* than 0."
*/
if ( result.length > 1 ) {
const value = result[ 1 ].trim().toLowerCase();
state.object.smooth = value !== '0' && value !== 'off';
} else {
// ZBrush can produce "s" lines #11707
state.object.smooth = true;
}
const material = state.object.currentMaterial();
if ( material ) material.smooth = state.object.smooth;
} else {
// Handle null terminated files without exception
if ( line === '\0' ) continue;
console.warn( 'THREE.OBJLoader: Unexpected line: "' + line + '"' );
}
}
state.finalize();
const container = new THREE.Group();
container.materialLibraries = [].concat( state.materialLibraries );
const hasPrimitives = ! ( state.objects.length === 1 && state.objects[ 0 ].geometry.vertices.length === 0 );
if ( hasPrimitives === true ) {
for ( let i = 0, l = state.objects.length; i < l; i ++ ) {
const object = state.objects[ i ];
const geometry = object.geometry;
const materials = object.materials;
const isLine = geometry.type === 'Line';
const isPoints = geometry.type === 'Points';
let hasVertexColors = false;
// Skip o/g line declarations that did not follow with any faces
if ( geometry.vertices.length === 0 ) continue;
const buffergeometry = new THREE.BufferGeometry();
buffergeometry.setAttribute( 'position', new THREE.Float32BufferAttribute( geometry.vertices, 3 ) );
if ( geometry.normals.length > 0 ) {
buffergeometry.setAttribute( 'normal', new THREE.Float32BufferAttribute( geometry.normals, 3 ) );
}
if ( geometry.colors.length > 0 ) {
hasVertexColors = true;
buffergeometry.setAttribute( 'color', new THREE.Float32BufferAttribute( geometry.colors, 3 ) );
}
if ( geometry.hasUVIndices === true ) {
buffergeometry.setAttribute( 'uv', new THREE.Float32BufferAttribute( geometry.uvs, 2 ) );
}
// Create materials
const createdMaterials = [];
for ( let mi = 0, miLen = materials.length; mi < miLen; mi ++ ) {
const sourceMaterial = materials[ mi ];
const materialHash = sourceMaterial.name + '_' + sourceMaterial.smooth + '_' + hasVertexColors;
let material = state.materials[ materialHash ];
if ( this.materials !== null ) {
material = this.materials.create( sourceMaterial.name );
// mtl etc. loaders probably can't create line materials correctly, copy properties to a line material.
if ( isLine && material && ! ( material instanceof THREE.LineBasicMaterial ) ) {
const materialLine = new THREE.LineBasicMaterial();
THREE.Material.prototype.copy.call( materialLine, material );
materialLine.color.copy( material.color );
material = materialLine;
} else if ( isPoints && material && ! ( material instanceof THREE.PointsMaterial ) ) {
const materialPoints = new THREE.PointsMaterial( {
size: 10,
sizeAttenuation: false
} );
THREE.Material.prototype.copy.call( materialPoints, material );
materialPoints.color.copy( material.color );
materialPoints.map = material.map;
material = materialPoints;
}
}
if ( material === undefined ) {
if ( isLine ) {
material = new THREE.LineBasicMaterial();
} else if ( isPoints ) {
material = new THREE.PointsMaterial( {
size: 1,
sizeAttenuation: false
} );
} else {
material = new THREE.MeshPhongMaterial();
}
material.name = sourceMaterial.name;
material.flatShading = sourceMaterial.smooth ? false : true;
material.vertexColors = hasVertexColors;
state.materials[ materialHash ] = material;
}
createdMaterials.push( material );
}
// Create mesh
let mesh;
if ( createdMaterials.length > 1 ) {
for ( let mi = 0, miLen = materials.length; mi < miLen; mi ++ ) {
const sourceMaterial = materials[ mi ];
buffergeometry.addGroup( sourceMaterial.groupStart, sourceMaterial.groupCount, mi );
}
if ( isLine ) {
mesh = new THREE.LineSegments( buffergeometry, createdMaterials );
} else if ( isPoints ) {
mesh = new THREE.Points( buffergeometry, createdMaterials );
} else {
mesh = new THREE.Mesh( buffergeometry, createdMaterials );
}
} else {
if ( isLine ) {
mesh = new THREE.LineSegments( buffergeometry, createdMaterials[ 0 ] );
} else if ( isPoints ) {
mesh = new THREE.Points( buffergeometry, createdMaterials[ 0 ] );
} else {
mesh = new THREE.Mesh( buffergeometry, createdMaterials[ 0 ] );
}
}
mesh.name = object.name;
container.add( mesh );
}
} else {
// if there is only the default parser state object with no geometry data, interpret data as point cloud
if ( state.vertices.length > 0 ) {
const material = new THREE.PointsMaterial( {
size: 1,
sizeAttenuation: false
} );
const buffergeometry = new THREE.BufferGeometry();
buffergeometry.setAttribute( 'position', new THREE.Float32BufferAttribute( state.vertices, 3 ) );
if ( state.colors.length > 0 && state.colors[ 0 ] !== undefined ) {
buffergeometry.setAttribute( 'color', new THREE.Float32BufferAttribute( state.colors, 3 ) );
material.vertexColors = true;
}
const points = new THREE.Points( buffergeometry, material );
container.add( points );
}
}
return container;
}
}
THREE.OBJLoader = OBJLoader;
} )();

File diff suppressed because one or more lines are too long