Building a 3D Smart Archive and Mobile Shelving System with Three.js
3D Archive Room Visualization
A 3D archive facility can be divided into multiple distinct zones and complex corner rooms. Within this environment, mobile shelving units (compact shelves) can be interacted with directly. Clicking a shelf unit displays its statistical data, including face count, tier count, column count, and space utilization. Automated compact shelves can also be controlled to open aisles or collapse units via hardware protocol integration.
Mobile Shelving Configuration and Movement
The core challenge of animating mobile shelves is calculating the movement distance, the number of shelves to move, and tracking their positions. A configuration-driven approach allows defining the initial state, fixed columns, and valid movement directions without hardcoding logical branches.
const zoneConfig = [[11, 23, 13, 11, 23, 13, 25, 17]];
const staticColumns = [[[1], [12], [13], [1], [12], [13], [13], [9]]];
const shiftDirection = [[
["x", -1], ["x", -1], ["x", -1],
["x", -1], ["x", -1], ["x", -1],
["x", -1], ["x", -1]
]];Using these configurations, generic methods handle the exclusive and non-exclusive movement of the shelves.
ArchiveController.prototype.shiftShelfExclusive = function(target, direction, distance) {
if (this.isMoving) {
console.warn("Shelves are currently in motion.");
return;
}
this.isMoving = true;
const params = this.calculateMovementParams(target, direction);
const axisKey = params.axisStr;
const upperAxisKey = axisKey.toUpperCase();
const affectedObjects = SceneEngine.utils.getObjectsByName(params.targetNames);
const movableItems = [];
affectedObjects.forEach(item => {
if (!item.initialX) item.initialX = item.position.x;
if (!item.initialZ) item.initialZ = item.position.z;
let offset = 0;
if (Math.abs(item.position[axisKey] - item[`initial${upperAxisKey}`]) > 10) {
offset = item.position[axisKey] - item[`initial${upperAxisKey}`];
}
if (offset === 0 && params.directionVal === params.restrictedVal) {
movableItems.push(item);
} else if (offset !== 0 && params.directionVal !== params.restrictedVal) {
movableItems.push(item);
}
});
const tweenData = { progress: 0 };
movableItems.forEach(obj => obj[`start${axisKey}`] = obj.position[axisKey]);
new TWEEN.Tween(tweenData).to({ progress: distance }, 200)
.onUpdate(() => {
movableItems.forEach(obj => {
obj.position[axisKey] = obj[`start${axisKey}`] + tweenData.progress * params.directionVal;
});
})
.onComplete(() => { this.isMoving = false; })
.start();
};
ArchiveController.prototype.collapseAllShelves = function(target, duration, callback) {
const bindInfo = this.getShelfBinding(target.name);
const namePrefix = target.name.split('_').slice(0, 2).join('_') + '_';
const shelfNames = [];
for (let i = 1; i <= bindInfo.maxCols; i++) shelfNames.push(namePrefix + i);
const shelfObjects = SceneEngine.utils.getObjectsByName(shelfNames);
shelfObjects.forEach(obj => {
new TWEEN.Tween(obj.position).to({
x: obj.initialX,
z: obj.initialZ
}, duration || 200).onComplete(callback).start();
});
};Ventilation and Lock Animations
Operations like ventilation, locking, and unlocking are visualized by dynamically loading animated models and destroying them after the animation completes.
ArchiveController.prototype.toggleVentilation = function(isOpen, worldPos) {
const modelDef = [{ "name": "flow_tube", "objType": "flowTube", "points": [...], "position": worldPos, "style": { "skinColor": 16772846, "canvasSkin": { "run": true } } }];
SceneEngine.loader.loadFromJson(modelDef, {x:0,y:0,z:0}, {x:0,y:0,z:0}, true);
setTimeout(() => {
const flowMesh = SceneEngine.utils.getObject("flow_tube");
if (isOpen) {
console.info("Activating ventilation...");
setTimeout(() => {
SceneEngine.materials.fadeOpacity([flowMesh], 1, 0.1, 800);
setTimeout(() => {
flowMesh.visible = false;
SceneEngine.disposeObject(flowMesh);
}, 500);
}, 5000);
} else {
setTimeout(() => {
console.info("Deactivating ventilation...");
new TWEEN.Tween(flowMesh.scale).to({x:20, y:20, z:20}, 1000)
.onComplete(() => {
flowMesh.visible = false;
flowMesh.scale.set(0.001, 0.001, 0.001);
setTimeout(() => SceneEngine.disposeObject(flowMesh), 200);
}).start();
}, 1000);
}
}, 200);
};
ArchiveController.prototype.animateLock = function(worldPos, isUnlocked) {
const modelDef = isUnlocked ? this.buildOpenLockModel() : this.buildClosedLockModel();
SceneEngine.loader.loadFromJson([modelDef], worldPos, {x:0,y:0,z:0}, true);
setTimeout(() => {
const lockMesh = SceneEngine.utils.getObject("dynamic_lock");
const latch = lockMesh.children[1];
latch.baseY = latch.position.y;
if (isUnlocked) {
new TWEEN.Tween({y: 0}).to({y: 25}, 500)
.onUpdate(function() { latch.position.y = latch.baseY + this.y; })
.onComplete(() => {
new TWEEN.Tween(latch.rotation).to({y: Math.PI}, 1000)
.onComplete(() => this.dissolveAndRemove(lockMesh)).start();
}).start();
} else {
new TWEEN.Tween(latch.rotation).to({y: Math.PI}, 1000)
.onComplete(() => {
new TWEEN.Tween({y: 0}).to({y: -25}, 500)
.onUpdate(function() { latch.position.y = latch.baseY + this.y; })
.onComplete(() => this.dissolveAndRemove(lockMesh)).start();
}).start();
}
}, 200);
};Equipment Data Overlays
Environmental sensors and zone controllers display real-time monitoring data. This is achieved by projecting 3D world coordinates to 2D screen coordinates and rendering an HTML tooltip at that position.
ArchiveController.prototype.displayOverlayTooltip = function(mesh, offset, markup, onClose) {
const worldPoint = {
x: mesh.position.x + offset.x,
y: mesh.position.y + offset.y,
z: mesh.position.z + offset.z
};
const screenCoord = SceneEngine.camera.projectToScreen(worldPoint);
document.getElementById('tooltip_anchor')?.remove();
document.body.insertAdjacentHTML('beforeend',
`<div id="tooltip_anchor" style="position:absolute;left:${screenCoord.x - 30}px;top:${screenCoord.y}px;z-index:1000;"></div>`);
TooltipUI.show(markup, '#tooltip_anchor', {
closeBtn: true, shadeClose: true, area: ["300px", "200px"],
time: 0, cancel: onClose, theme: "dark"
});
};Virtual Exhibition Room Implementation
A smaller exhibition room incorporates various IoT devices like track cameras, thermo-hygrometers, alarm lights, lighting controls, access doors, and RFID readers. Due to the limited number of shelves, movement positions can be strictly coupled.
Hardcoded Shelf Control
ArchiveController.prototype.controlCompactShelves = function() {
const controlsHtml = `
<div class="ctrl-btn" data-action="open1">Open Aisle 1</div>
<div class="ctrl-btn" data-action="open2">Open Aisle 2</div>
<div class="ctrl-btn" data-action="close">Close All</div>`;
this.displayOverlayTooltip(null, null, 300, controlsHtml, () => {
document.querySelectorAll('.ctrl-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const action = e.currentTarget.dataset.action;
const shelf2 = SceneEngine.utils.getObject("shelf_row_2");
const shelf3 = SceneEngine.utils.getObject("shelf_row_3");
if (action === "open1") {
shelf2.position.x = 2300; shelf3.position.x = 1900;
} else if (action === "open2") {
shelf2.position.x = 2879; shelf3.position.x = 1500;
} else {
shelf2.position.x = 2879; shelf3.position.x = 2428;
}
ApiService.sendCommand("shelves/control", action);
});
});
});
};Internal Archive Inspection
Double-clicking a shelf zooms the camera in and reveals the internal structure. If archive boxes exist within the shelf, they are dynamically rendered. Clicking a box displays its spine details and a list of internal files (PDFs, Word docs, images).
function inspectShelfBox(element) {
const row = element.name.split('_')[1];
const col = element.name.split('_')[2];
const isLeftFace = element.name.includes("lattice1");
const faceId = isLeftFace ? 0 : 1;
const detailData = DataCache[`r${faceId}_${row}_c_${col}`];
DialogUI.open({
title: `${isLeftFace ? 'Left' : 'Right'} Side, Row ${row}, Col ${col}`,
content: '<div id="boxContainer" style="display:flex;overflow-x:auto;"></div>',
onReady: () => {
ApiService.fetchArchiveBoxes(roomId, faceId, row, col, (boxes) => {
let html = '';
boxes.sort((a, b) => a.sortOrder - b.sortOrder).forEach(box => {
html += renderBoxSpine(box.type, box);
});
document.getElementById('boxContainer').innerHTML = html;
document.querySelectorAll('.box-item').forEach(el => {
el.addEventListener('click', (e) => {
const id = e.currentTarget.dataset.id;
ApiService.fetchBoxFiles(id, (files) => {
const fileLinks = files.map(f
=> `<a href="${f.url}" target="_blank">${f.name}</a>`).join('<br/>');
TooltipUI.anchor(fileLinks, e.currentTarget);
});
});
});
});
}
});
}IoT Device Integration
Track cameras are moved by adjusting their position property. Access doors are manipulated by altering both position and rotation. Lighting effects are toggled by modifying the visibility of directional lights to cast dynamic shadows. Thermo-hygrometers and alarms trigger predefined animation sequences.
ArchiveController.prototype.moveTrackCamera = function() {
const controlsHtml = `
<div class="ctrl-btn" data-loc="3200">Aisle 1</div>
<div class="ctrl-btn" data-loc="2300">Aisle 2</div>
<div class="ctrl-btn" data-loc="1400">Origin</div>`;
this.displayOverlayTooltip(null, null, 300, controlsHtml, () => {
document.querySelectorAll('.ctrl-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const xCoord = parseInt(e.currentTarget.dataset.loc);
SceneEngine.utils.getObject("track_cam_1").position.x = xCoord;
ApiService.sendCommand("camera/move", xCoord);
});
});
});
};
ArchiveController.prototype.operateAccessDoor = function() {
const controlsHtml = `
<div class="ctrl-btn" data-action="close_door">Lock Door</div>
<div class="ctrl-btn" data-action="open_door">Unlock Door</div>`;
this.displayOverlayTooltip(null, null, 200, controlsHtml, () => {
document.querySelectorAll('.ctrl-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const action = e.currentTarget.dataset.action;
const leftDoor = SceneEngine.utils.getObject("door_left");
const rightDoor = SceneEngine.utils.getObject("door_right");
if (action === "close_door") {
leftDoor.position.set(-163.3, 0, 2680.7); leftDoor.rotation.y = 0;
rightDoor.position.set(-163.3, 0, 3081.6); rightDoor.rotation.y = 0;
} else {
leftDoor.position.set(74, 0, 2500); leftDoor.rotation.y = Math.PI / 2;
rightDoor.position.set(74, 0, 3250); rightDoor.rotation.y = -Math.PI / 2;
}
ApiService.sendCommand("door/control", action);
});
});
});
};
ArchiveController.prototype.toggleRoomLights = function() {
const controlsHtml = `
<div class="ctrl-btn" data-action="lights_off">Switch Off</div>
<div class="ctrl-btn" data-action="lights_on">Switch On</div>`;
this.displayOverlayTooltip(null, null, 200, controlsHtml, () => {
document.querySelectorAll('.ctrl-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const action = e.currentTarget.dataset.action;
const mainLight = SceneEngine.utils.getObject("main_directional_light");
mainLight.visible = (action === "lights_on");
ApiService.sendCommand("lighting/control", action);
});
});
});
};