Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Empty worlds, checkTargetFiles, and renderMode #1017

Merged
merged 33 commits into from
Mar 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
a387843
adds checkFileExists route to object router/controller
benptc Jan 10, 2024
aec9f98
Merge branch 'object-targetId' into placeholder-world
benptc Jan 11, 2024
f884b17
world object entry on localhost:8080 is clickable to go to remote ope…
benptc Jan 11, 2024
88c24e7
send world object heartbeats even if they have no target files (simil…
benptc Jan 12, 2024
c2fe1c4
Merge branch 'object-targetId' into placeholder-world
benptc Jan 12, 2024
d80b29e
dont crash if no authoringMesh.glb inside the upload.zip
benptc Jan 12, 2024
8ed0cfc
more completely cleanup target folder after unzipping target files
benptc Jan 12, 2024
21b31ce
Merge branch 'master' into placeholder-world
benptc Jan 12, 2024
064a0c9
Merge branch 'master' into placeholder-world
benptc Jan 12, 2024
841494c
Merge branch 'main' into placeholder-world-ux
benptc Feb 12, 2024
dc2a8d0
dont generate XML file when world object is created or when other tar…
benptc Feb 16, 2024
6640c04
allow upload of target.splat to the object
benptc Feb 16, 2024
f467e81
Merge branch 'main' into placeholder-world-ux
benptc Feb 19, 2024
8a7068e
adds new all-in-one /checkTargetFiles/ API instead of needing to do a…
benptc Feb 21, 2024
7cf4013
Merge branch 'main' into placeholder-world-ux
benptc Feb 23, 2024
7b35a37
adds new /object/:id/uploadTarget/ route with cleaner logic than old …
benptc Feb 27, 2024
f54bda1
properly await checkFileExists
benptc Feb 27, 2024
1b8b3fc
Update toolsocket
hobinjk-ptc Feb 28, 2024
37aa150
add try-catch around formidable parse to guard against malformed targ…
benptc Mar 1, 2024
2abc9b7
fix linting for updated target uploads
benptc Mar 1, 2024
389df03
Merge branch 'gs-upload-test' of github.com:ptcrealitylab/vuforia-spa…
benptc Mar 1, 2024
375bdbb
Merge branch 'main' into gs-upload-test
benptc Mar 1, 2024
f1ec50c
cleanup uploadTarget function
benptc Mar 4, 2024
c00ce1a
cleanup gs-upload-test
benptc Mar 4, 2024
4383f4e
adds /object/renderMode API to post updates from mesh to ai rendering…
benptc Mar 6, 2024
21e3fae
Merge branch 'main' into gs-upload-test
benptc Mar 7, 2024
af2a1ac
Merge branch 'main' into gs-upload-test
hobinjk-ptc Mar 8, 2024
9409e49
cleanup gs-upload-test
benptc Mar 14, 2024
cad75c8
sanitize checkFileExists path
benptc Mar 14, 2024
3cd5b17
cleanup gs-upload-test
benptc Mar 14, 2024
2c33249
fix linting error
benptc Mar 15, 2024
dcbdbbe
extracts targetId from dat file when uploaded individually, in additi…
benptc Mar 15, 2024
a4ac496
fix circular dependency and add renderMode to test expected result
benptc Mar 15, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
233 changes: 231 additions & 2 deletions controllers/object.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const formidable = require('formidable');
const utilities = require('../libraries/utilities');
const {fileExists, unlinkIfExists, mkdirIfNotExists} = utilities;
const {startSplatTask} = require('./object/SplatTask.js');
const { beatPort } = require('../config.js');

// Variables populated from server.js with setup()
var objects = {};
Expand All @@ -26,6 +27,7 @@ let objectLookup;
let activeHeartbeats;
let knownObjects;
let setAnchors;
let objectBeatSender;

const deleteObject = async function(objectID) {
let object = utilities.getObject(objects, objectID);
Expand Down Expand Up @@ -219,12 +221,35 @@ const setMatrix = function(objectID, body, callback) {
callback(200, {success: true});
};

/**
* Sets the renderMode of an object (e.g. 'mesh' or 'ai') and sends the new state to all clients via action message
* @param {string} objectID
* @param {Request.body} body
* @param {function} callback
*/
const setRenderMode = async (objectID, body, callback) => {
let object = utilities.getObject(objects, objectID);
if (!object) {
callback(404, {failure: true, error: 'Object ' + objectID + ' not found'});
return;
}
if (typeof body.renderMode === 'undefined') {
callback(400, {failure: true, error: `Bad request: no renderMode specified in body`});
}

object.renderMode = body.renderMode;

await utilities.writeObjectToFile(objects, objectID, globalVariables.saveToDisk);
utilities.actionSender({reloadObject: {object: objectID}, lastEditor: body.lastEditor});
callback(200, {success: true});
};

/**
* Upload an image file to the object's metadata folder.
* The image is stored in a form, which can be parsed and written to the filesystem.
* @param {string} objectID
* @param {express.Request} req
* @param {express.Response} res
* @param {function} callback
*/
const memoryUpload = async function(objectID, req, callback) {
if (!objects.hasOwnProperty(objectID)) {
Expand Down Expand Up @@ -392,6 +417,205 @@ const setFrameSharingEnabled = function (objectKey, shouldBeEnabled, callback) {
console.warn('TODO: implement frame sharing... need to set property and implement all side-effects / consequences');
};

/**
* Check whether a specific file within the object's .identity folder exists
* @param {string} objectId
* @param {string} filePath
* @returns {Promise<boolean>}
*/
const checkFileExists = async (objectId, filePath) => {
let obj = utilities.getObject(objects, objectId);
if (!obj) {
return false;
}

// prevent upward traversal so this can't be used maliciously. Only allow certain characters, and prevent ../
// eslint-disable-next-line no-useless-escape
let isSafePath = /^[a-zA-Z0-9_.\-\/]+$/.test(filePath) && !/\.\.\//.test(filePath);
if (!isSafePath) {
return false;
}

let objectIdentityDir = path.join(objectsPath, obj.name, identityFolderName);
let absoluteFilePath = path.join(objectIdentityDir, filePath);
return await fileExists(absoluteFilePath);
};

/**
* Check the status of all the possible target files that an object might have
* @param {string} objectId
* @returns {Promise<{glbExists: boolean, xmlExists: boolean, datExists: boolean, jpgExists: boolean, _3dtExists: boolean, splatExists: boolean}>}
*/
const checkTargetFiles = async (objectId) => {
let [
glbExists, xmlExists, datExists,
jpgExists, _3dtExists, splatExists
] = await Promise.all([
checkFileExists(objectId, '/target/target.glb'),
checkFileExists(objectId, '/target/target.xml'),
checkFileExists(objectId, '/target/target.dat'),
checkFileExists(objectId, '/target/target.jpg'),
checkFileExists(objectId, '/target/target.3dt'),
checkFileExists(objectId, '/target/target.splat'),
]);
return {
glbExists,
xmlExists,
datExists,
jpgExists,
_3dtExists,
splatExists
};
};

/**
* Uploads a target file to an object.
* Imitates much of the logic of the content/:id route in a simpler way.
* Note: not supported by Cloud Proxy as of 3-4-2024, use content/:id instead.
* @param {string} objectName
* @param {Request} req
* @param {Response} res
* @returns {Promise<void>}
*/
const uploadTarget = async (objectName, req, res) => {
let thisObjectId = utilities.readObject(objectLookup, objectName);
let object = utilities.getObject(objects, thisObjectId);
if (!object) {
res.status(404).send('object ' + thisObjectId + ' not found');
return;
}

// first upload to a temporary directory (tmp/) before moving it to the target directory (target/)
let uploadDir = path.join(objectsPath, objectName, identityFolderName, 'tmp');
await mkdirIfNotExists(uploadDir);
let targetDir = path.join(objectsPath, objectName, identityFolderName, 'target');
await mkdirIfNotExists(targetDir);

let form = new formidable.IncomingForm({
uploadDir: uploadDir,
keepExtensions: true
});

form.on('error', function (err) {
res.status(500).send(err);
});

let fileInfoList = [];

form.on('fileBegin', (name, file) => {
if (!file.name) {
file.name = file.newFilename; // some versions of formidable use .name, others use .newFilename
}
fileInfoList.push({
name: file.name,
completed: false
});
file.path = path.join(form.uploadDir, file.name);
});

form.parse(req);

/**
* Returns the file extension (portion after the last dot) of the given filename.
* If a file name starts with a dot, returns an empty string.
*
* @author VisioN @ StackOverflow
* @param {string} fileName - The name of the file, such as foo.zip
* @return {string} The lowercase extension of the file, such has "zip"
*/
function getFileExtension(fileName) {
return fileName.substr((~-fileName.lastIndexOf('.') >>> 0) + 2).toLowerCase();
}

/**
* Returns a promise that can be executed later to move the uploaded file from the tmp dir to the target dir
* @param {{name: string, completed: boolean}} fileInfo
* @returns {Promise<unknown>}
*/
function makeFileProcessPromise(fileInfo) {
return new Promise((resolve, reject) => {
(async () => {
if (!await fileExists(path.join(form.uploadDir, fileInfo.name))) { // Ignore files that haven't finished uploading
reject(`File doesn't exist at ${path.join(form.uploadDir, fileInfo.name)}`);
return;
}
fileInfo.completed = true; // File has downloaded
let fileExtension = getFileExtension(fileInfo.name);

// only accept these predetermined file types
if (!(fileExtension === 'jpg' || fileExtension === 'dat' ||
fileExtension === 'xml' || fileExtension === 'glb' ||
fileExtension === '3dt' || fileExtension === 'splat')) {
reject(`File extension not acceptable for targetUpload (${fileExtension})`);
return;
}

let originalFilepath = path.join(uploadDir, fileInfo.name);
let newFilepath = path.join(targetDir, `target.${fileExtension}`);

try {
await fsProm.rename(originalFilepath, newFilepath);
resolve();
} catch (e) {
reject(`error renaming ${originalFilepath} to ${newFilepath}`);
}
})();
});
}

form.on('end', async () => {
fileInfoList = fileInfoList.filter(fileInfo => !fileInfo.completed); // Don't repeat processing for completed files

let filePromises = [];
fileInfoList.forEach(fileInfo => {
filePromises.push(makeFileProcessPromise(fileInfo));
});

try {
await Promise.all(filePromises);
// Code continues when all promises are resolved (when all files have been moved from tmp/ to target/)
} catch (error) {
console.warn(error);
// res.status(500).send('error')
}

let jpgPath = path.join(targetDir, 'target.jpg');
let datPath = path.join(targetDir, 'target.dat');
let xmlPath = path.join(targetDir, 'target.xml');
let glbPath = path.join(targetDir, 'target.glb');
let tdtPath = path.join(targetDir, 'target.3dt');
let splatPath = path.join(targetDir, 'target.splat');
let fileList = [jpgPath, xmlPath, datPath, glbPath, tdtPath, splatPath];

let sendObject = {
id: thisObjectId,
name: objectName,
// initialized: (jpg && xml),
jpgExists: await fileExists(jpgPath),
xmlExists: await fileExists(xmlPath),
datExists: await fileExists(datPath),
glbExists: await fileExists(glbPath),
tdtExists: await fileExists(tdtPath),
splatExists: await fileExists(splatPath)
};

object.tcs = utilities.generateChecksums(objects, fileList);
await utilities.writeObjectToFile(objects, thisObjectId, globalVariables.saveToDisk);
await setAnchors();

await objectBeatSender(beatPort, thisObjectId, objects[thisObjectId].ip, true);

// delete the tmp folder and any files within it
await utilities.rmdirIfExists(uploadDir);

try {
res.status(200).json(sendObject);
} catch (e) {
console.error('unable to send res', e);
}
});
};

const getObject = function (objectID, excludeUnpinned) {
let fullObject = utilities.getObject(objects, objectID);
if (!fullObject) { return null; }
Expand All @@ -416,7 +640,7 @@ const getObject = function (objectID, excludeUnpinned) {
};

const setup = function (objects_, globalVariables_, hardwareAPI_, objectsPath_, sceneGraph_,
objectLookup_, activeHeartbeats_, knownObjects_, setAnchors_) {
objectLookup_, activeHeartbeats_, knownObjects_, setAnchors_, objectBeatSender_) {
objects = objects_;
globalVariables = globalVariables_;
hardwareAPI = hardwareAPI_;
Expand All @@ -426,6 +650,7 @@ const setup = function (objects_, globalVariables_, hardwareAPI_, objectsPath_,
activeHeartbeats = activeHeartbeats_;
knownObjects = knownObjects_;
setAnchors = setAnchors_;
objectBeatSender = objectBeatSender_;
};

module.exports = {
Expand All @@ -435,13 +660,17 @@ module.exports = {
saveCommit: saveCommit,
resetToLastCommit: resetToLastCommit,
setMatrix: setMatrix,
setRenderMode: setRenderMode,
memoryUpload: memoryUpload,
deactivate: deactivate,
activate: activate,
setVisualization: setVisualization,
zipBackup: zipBackup,
generateXml: generateXml,
setFrameSharingEnabled: setFrameSharingEnabled,
checkFileExists: checkFileExists,
checkTargetFiles: checkTargetFiles,
uploadTarget: uploadTarget,
getObject: getObject,
setup: setup,
requestGaussianSplatting,
Expand Down
59 changes: 37 additions & 22 deletions libraries/webInterface/gui/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -543,12 +543,16 @@ realityServer.updateManageObjects = function (thisItem2) {

} else { // if not initializes with target files...

thisObject.dom.querySelector('.name').classList.add('inactive');
thisObject.dom.querySelector('.zone').classList.add('inactive');
thisObject.dom.querySelector('.sharing').classList.add('inactive');
thisObject.dom.querySelector('.download').classList.add('inactive');
thisObject.dom.querySelector('.active').classList.add('inactive');

// world object names are always clickable to get to remote operator (assuming remote operator addon exists)
if (isRemoteOperatorSupported) {
realityServer.changeActiveState(thisObject.dom, true, objectKey);
}

// make on/off button yellow always if not properly initialized
realityServer.switchClass(thisObject.dom.querySelector('.active'), 'green', 'yellow');
thisObject.dom.querySelector('.active').innerText = 'Off';
Expand Down Expand Up @@ -1962,29 +1966,40 @@ realityServer.gotClick = function (event) {
realityServer.objects[objectName].isWorldObject = true;
}
} else {
// this is how world objects get instantly initialized
try {
// check if we need to generate an XML file, then setup and activate the object...
let msgContent = JSON.parse(state);
// generate a placeholder xml file for this object
let defaultSize = 0.3;
realityServer.sendRequest('/object/' + msgContent.id + '/generateXml/', 'POST', function (stateGen) {
if (stateGen === 'ok') {
realityServer.objects[msgContent.id] = new Objects();
realityServer.objects[msgContent.id].name = msgContent.name;
realityServer.objects[msgContent.id].isWorldObject = true;
// realityServer.objects[msgContent.id].initialized = true;

// make them automatically activate after a slight delay
setTimeout(function () {
realityServer.sendRequest('/object/' + msgContent.id + '/activate/', 'GET', function (stateActivate) {
if (stateActivate === 'ok') {
// realityServer.objects[msgContent.id].active = true;
}
realityServer.update();
});
}, 100);
}
}, 'name=' + msgContent.name + '&width=' + defaultSize + '&height=' + defaultSize);

const finishSettingUpObject = () => {
realityServer.objects[msgContent.id] = new Objects();
realityServer.objects[msgContent.id].name = msgContent.name;
realityServer.objects[msgContent.id].isWorldObject = true;
// realityServer.objects[msgContent.id].initialized = true;

// make them automatically activate after a slight delay
setTimeout(function () {
realityServer.sendRequest('/object/' + msgContent.id + '/activate/', 'GET', function (stateActivate) {
if (stateActivate === 'ok') {
// realityServer.objects[msgContent.id].active = true;
}
realityServer.update();
});
}, 100);
};

// World objects can be totally empty, regular objects generate a default XML target file on creation
const AUTO_GENERATE_XML = !shouldAddWorldObject;
if (AUTO_GENERATE_XML) {
// generate a placeholder xml file for this object
let defaultSize = 0.3;
realityServer.sendRequest('/object/' + msgContent.id + '/generateXml/', 'POST', function (stateGen) {
if (stateGen === 'ok') {
finishSettingUpObject();
}
}, 'name=' + msgContent.name + '&width=' + defaultSize + '&height=' + defaultSize);
} else {
finishSettingUpObject();
}

} catch (e) {
console.error('json parse error for (action=new&name=\'' + objectName + '\') response: ' + state);
Expand Down
1 change: 1 addition & 0 deletions models/ObjectModel.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ function ObjectModel(ip, version, protocol, objectId) {
this.type = 'object'; // or: 'world' or 'human' or 'avatar' etc...
this.timestamp = null; // timestamp optionally stores when the object was first created
this.gaussianSplatRequestId = null; // Optional id for in-progress request to GS server
this.renderMode = null; // Can be set to 'mesh' or 'ai' to synchronize render mode across clients
}

/**
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading