1.1K tris3.13 × 2.89 × 1.10 m
Use it in code
Remix in the editorportal.js · 238 lines
Self-contained ES module — only depends on three. Call the factory and add the group to your scene.
/**
* Portal — Codels 3D model library
* License: CC0-1.0 · Units: meters · Y-up · Front: +Z · Origin: center of the base (y = 0)
* Triangles: 1,148 · Size (W × H × D): 3.13 × 2.89 × 1.10 m
*
* Usage:
* import { createPortal } from './portal.js';
*
* const model = createPortal();
* model.position.set(0, 0, 0);
* scene.add(model);
*
* // Animations: idle
* const mixer = new THREE.AnimationMixer(model);
* mixer.clipAction(THREE.AnimationClip.findByName(model.animations, 'idle')).play();
* // in your render loop: mixer.update(clock.getDelta());
*/
import * as THREE from 'three';
const deg = THREE.MathUtils.degToRad;
// Extrude a 2D outline along Z (centered). bevel = [thickness, size, segments].
function extrude(outline, depth, holes = [], bevel = null) {
const shape = new THREE.Shape(outline.map(([x, y]) => new THREE.Vector2(x, y)));
for (const hole of holes) shape.holes.push(new THREE.Path(hole.map(([x, y]) => new THREE.Vector2(x, y))));
const options = bevel
? { depth, bevelEnabled: true, bevelThickness: bevel[0], bevelSize: bevel[1], bevelSegments: bevel[2] }
: { depth, bevelEnabled: false };
return new THREE.ExtrudeGeometry(shape, options).translate(0, 0, -depth / 2);
}
// Build animation clips from simple descriptions (Codels). Play them with THREE.AnimationMixer.
function codelsClips(THREE, entries) {
var TAU = Math.PI * 2;
var DEG = Math.PI / 180;
var AX = { x: new THREE.Vector3(1, 0, 0), y: new THREE.Vector3(0, 1, 0), z: new THREE.Vector3(0, 0, 1) };
var periodOf = function (a) { return a.type === 'spin' ? 360 / Math.abs(a.speed) : a.period; };
var clips = [];
// loops → 'idle'
var loops = [];
entries.forEach(function (e) {
var l = e.anim.filter(function (a) { return a.type !== 'hinge'; });
if (l.length) loops.push({ target: e.target, anim: l });
});
if (loops.length) {
var periods = [];
loops.forEach(function (e) { e.anim.forEach(function (a) { periods.push(periodOf(a)); }); });
var longest = Math.max.apply(null, periods);
var D = longest;
var bestErr = Infinity;
for (var m = 1; m <= 6 && bestErr > 0.01; m++) {
var err = 0;
periods.forEach(function (p) {
var n = Math.max(1, Math.round((longest * m) / p));
err = Math.max(err, Math.abs((longest * m) / n - p) / p);
});
if (err < bestErr - 1e-9) { bestErr = err; D = longest * m; }
}
var cycles = function (a) { return Math.max(1, Math.round(D / periodOf(a))); };
var steps = 24;
loops.forEach(function (e) { e.anim.forEach(function (a) { steps = Math.max(steps, cycles(a) * 24); }); });
steps = Math.min(steps, 720);
var tracks = [];
loops.forEach(function (e) {
var o = e.target;
var q0 = o.quaternion.clone(), p0 = o.position.clone(), s0 = o.scale.clone();
var q = new THREE.Quaternion(), turn = new THREE.Quaternion(), p = new THREE.Vector3(), s = new THREE.Vector3();
var has = function (type) { return e.anim.some(function (a) { return a.type === type; }); };
var turns = has('spin') || has('swing'), moves = has('bob'), grows = has('pulse');
var times = [], qv = [], pv = [], sv = [];
for (var k = 0; k <= steps; k++) {
var t = (D * k) / steps;
q.copy(q0); p.copy(p0); s.copy(s0);
e.anim.forEach(function (a) {
var n = cycles(a);
var w = Math.sin(TAU * ((n * t) / D + (a.phase || 0)));
if (a.type === 'spin') q.multiply(turn.setFromAxisAngle(AX[a.axis], (a.speed < 0 ? -TAU : TAU) * ((n * t) / D)));
else if (a.type === 'swing') q.multiply(turn.setFromAxisAngle(AX[a.axis], a.angle * DEG * w));
else if (a.type === 'bob') p.addScaledVector(AX[a.axis], a.distance * w);
else if (a.type === 'pulse') s.multiplyScalar(1 + a.amount * w);
});
times.push(t);
if (turns) qv.push(q.x, q.y, q.z, q.w);
if (moves) pv.push(p.x, p.y, p.z);
if (grows) sv.push(s.x, s.y, s.z);
}
if (turns) tracks.push(new THREE.QuaternionKeyframeTrack(o.uuid + '.quaternion', times, qv));
if (moves) tracks.push(new THREE.VectorKeyframeTrack(o.uuid + '.position', times, pv));
if (grows) tracks.push(new THREE.VectorKeyframeTrack(o.uuid + '.scale', times, sv));
});
clips.push(new THREE.AnimationClip('idle', D, tracks));
}
// hinge moves → one-shot clips ('open' …)
var shots = {};
entries.forEach(function (e) {
e.anim.forEach(function (a) {
if (a.type !== 'hinge') return;
var name = a.clip || 'open';
(shots[name] = shots[name] || []).push({ target: e.target, a: a });
});
});
Object.keys(shots).forEach(function (name) {
var tracks = [];
var length = 0;
shots[name].forEach(function (h) {
var o = h.target, q0 = o.quaternion.clone(), q = new THREE.Quaternion(), turn = new THREE.Quaternion();
var n = Math.max(4, Math.ceil(Math.abs(h.a.angle) / 10));
var times = [], qv = [];
for (var k = 0; k <= n; k++) {
var f = k / n;
var eased = f * f * (3 - 2 * f);
q.copy(q0).multiply(turn.setFromAxisAngle(AX[h.a.axis], h.a.angle * DEG * eased));
times.push(h.a.duration * f);
qv.push(q.x, q.y, q.z, q.w);
}
length = Math.max(length, h.a.duration);
tracks.push(new THREE.QuaternionKeyframeTrack(o.uuid + '.quaternion', times, qv));
});
clips.push(new THREE.AnimationClip(name, length, tracks));
});
return clips;
}
export function createPortal() {
const root = new THREE.Group();
root.name = 'Portal';
// Materials
const frameMat = new THREE.MeshStandardMaterial({ name: 'frame', color: 0x6c6a82, roughness: 0.85, flatShading: true });
const plinthMat = new THREE.MeshStandardMaterial({ name: 'plinth', color: 0x4f4e61, roughness: 0.9, flatShading: true });
const discMat = new THREE.MeshStandardMaterial({ name: 'disc', color: 0x613eb1, roughness: 0.4, emissive: 0x8c5cff, emissiveIntensity: 0.75 });
const swirlMat = new THREE.MeshStandardMaterial({ name: 'swirl', color: 0xcbb6ff, roughness: 0.3, emissive: 0xc0a5ff, emissiveIntensity: 1.1, flatShading: true });
const gemMat = new THREE.MeshStandardMaterial({ name: 'gem', color: 0xaf8dff, roughness: 0.2, emissive: 0x8c5cff, emissiveIntensity: 1.3, flatShading: true });
// Shared geometries
const extrudeGeo = extrude([[-0.28, 0], [0.28, 0], [0.15, 0.475], [-0.15, 0.475]], 0.455);
const octahedronGeo = new THREE.OctahedronGeometry(0.126, 0);
// Parts
const plinthBase = new THREE.Mesh(new THREE.BoxGeometry(3.125, 0.12, 1.1), plinthMat);
plinthBase.name = 'plinthBase';
plinthBase.position.set(0, 0.06, 0);
root.add(plinthBase);
const plinthTop = new THREE.Mesh(new THREE.BoxGeometry(2.625, 0.121, 0.8), frameMat);
plinthTop.name = 'plinthTop';
plinthTop.position.set(0, 0.1795, 0);
root.add(plinthTop);
const footLeft = new THREE.Mesh(extrudeGeo, plinthMat);
footLeft.name = 'footLeft';
footLeft.position.set(-0.775, 0.22, 0);
footLeft.rotation.set(0, 0, deg(-36));
root.add(footLeft);
const footRight = new THREE.Mesh(extrudeGeo, plinthMat);
footRight.name = 'footRight';
footRight.position.set(0.775, 0.22, 0);
footRight.rotation.set(0, 0, deg(36));
root.add(footRight);
const ring = new THREE.Mesh(new THREE.TorusGeometry(1.25, 0.175, 6, 36), frameMat);
ring.name = 'ring';
ring.position.set(0, 1.4637, 0);
root.add(ring);
const rune1 = new THREE.Mesh(octahedronGeo, gemMat);
rune1.name = 'rune1';
rune1.position.set(1.1548, 1.9421, 0);
rune1.rotation.set(0, 0, deg(22.5));
rune1.scale.set(1, 1, 1.9);
root.add(rune1);
const rune2 = new THREE.Mesh(octahedronGeo, gemMat);
rune2.name = 'rune2';
rune2.position.set(0.4784, 2.6186, 0);
rune2.rotation.set(0, 0, deg(67.5));
rune2.scale.set(1, 1, 1.9);
root.add(rune2);
const rune3 = new THREE.Mesh(octahedronGeo, gemMat);
rune3.name = 'rune3';
rune3.position.set(-0.4784, 2.6186, 0);
rune3.rotation.set(0, 0, deg(112.5));
rune3.scale.set(1, 1, 1.9);
root.add(rune3);
const rune4 = new THREE.Mesh(octahedronGeo, gemMat);
rune4.name = 'rune4';
rune4.position.set(-1.1548, 1.9421, 0);
rune4.rotation.set(0, 0, deg(157.5));
rune4.scale.set(1, 1, 1.9);
root.add(rune4);
const rune5 = new THREE.Mesh(octahedronGeo, gemMat);
rune5.name = 'rune5';
rune5.position.set(-1.1548, 0.9854, 0);
rune5.rotation.set(0, 0, deg(202.5));
rune5.scale.set(1, 1, 1.9);
root.add(rune5);
const rune8 = new THREE.Mesh(octahedronGeo, gemMat);
rune8.name = 'rune8';
rune8.position.set(1.1548, 0.9854, 0);
rune8.rotation.set(0, 0, deg(337.5));
rune8.scale.set(1, 1, 1.9);
root.add(rune8);
const vortex = new THREE.Mesh(new THREE.CylinderGeometry(1.18, 1.18, 0.03, 32), discMat);
vortex.name = 'vortex';
vortex.position.set(0, 1.4637, 0);
vortex.rotation.set(deg(90), 0, 0);
root.add(vortex);
const vortexSwirl = new THREE.Group();
vortexSwirl.name = 'vortexSwirl';
vortexSwirl.position.set(0, 1.4637, 0);
root.add(vortexSwirl);
const swirl1 = new THREE.Mesh(extrude([[0.241, 0], [0.281, 0.0675], [0.3002, 0.153], [0.2927, 0.25], [0.2545, 0.3503], [0.184, 0.4443], [0.0827, 0.5224], [-0.0453, 0.5751], [-0.1931, 0.5943], [-0.3516, 0.5737], [-0.5097, 0.5097], [-0.6556, 0.4017], [-0.7769, 0.2524], [-0.8622, 0.0679], [-0.9016, -0.1428], [-0.8877, -0.3677], [-0.8161, -0.5929], [-0.6863, -0.8036], [-0.5016, -0.9843], [-0.4842, -0.9502], [-0.6556, -0.7677], [-0.7707, -0.5599], [-0.8275, -0.3428], [-0.8285, -0.1312], [-0.7795, 0.0613], [-0.6895, 0.224], [-0.5697, 0.3491], [-0.4322, 0.4322], [-0.2896, 0.4726], [-0.1537, 0.4731], [-0.0346, 0.4392], [0.06, 0.3789], [0.125, 0.3018], [0.1586, 0.2183], [0.1619, 0.1383], [0.139, 0.0708], [0.0964, 0.0231], [0.0422, 0]], 0.06), swirlMat);
swirl1.name = 'swirl1';
vortexSwirl.add(swirl1);
const swirl2 = new THREE.Mesh(extrude([[-0.1205, 0.2087], [-0.1989, 0.2096], [-0.2826, 0.1835], [-0.3629, 0.1285], [-0.4306, 0.0453], [-0.4768, -0.0628], [-0.4938, -0.1895], [-0.4754, -0.3268], [-0.4181, -0.4644], [-0.3211, -0.5913], [-0.1866, -0.6963], [-0.0201, -0.7686], [0.1698, -0.799], [0.3723, -0.7806], [0.5745, -0.7094], [0.7623, -0.5849], [0.9216, -0.4103], [1.0391, -0.1926], [1.1032, 0.0578], [1.065, 0.0558], [0.9926, -0.184], [0.8703, -0.3875], [0.7106, -0.5453], [0.5279, -0.6519], [0.3366, -0.7058], [0.1507, -0.7092], [-0.0175, -0.6679], [-0.1582, -0.5904], [-0.2645, -0.4871], [-0.3328, -0.3697], [-0.363, -0.2495], [-0.3581, -0.1375], [-0.3239, -0.0426], [-0.2683, 0.0282], [-0.2007, 0.0711], [-0.1308, 0.085], [-0.0682, 0.0719], [-0.0211, 0.0365]], 0.06), swirlMat);
swirl2.name = 'swirl2';
vortexSwirl.add(swirl2);
const swirl3 = new THREE.Mesh(extrude([[-0.1205, -0.2087], [-0.0821, -0.2771], [-0.0176, -0.3365], [0.0702, -0.3785], [0.1761, -0.3955], [0.2928, -0.3816], [0.411, -0.3329], [0.5207, -0.2484], [0.6112, -0.1299], [0.6726, 0.0176], [0.6963, 0.1866], [0.6757, 0.3669], [0.607, 0.5466], [0.4898, 0.7127], [0.3271, 0.8522], [0.1254, 0.9526], [-0.1054, 1.0033], [-0.3528, 0.9962], [-0.6017, 0.9265], [-0.5808, 0.8944], [-0.337, 0.9516], [-0.0996, 0.9474], [0.1169, 0.8881], [0.3006, 0.7831], [0.4429, 0.6444], [0.5388, 0.4851], [0.5872, 0.3188], [0.5904, 0.1582], [0.5541, 0.0145], [0.4865, -0.1034], [0.3976, -0.1896], [0.2981, -0.2414], [0.1989, -0.2592], [0.1097, -0.2465], [0.0388, -0.2093], [-0.0082, -0.1558], [-0.0281, -0.095], [-0.0211, -0.0365]], 0.06), swirlMat);
swirl3.name = 'swirl3';
vortexSwirl.add(swirl3);
const core = new THREE.Mesh(new THREE.CylinderGeometry(0.1888, 0.1888, 0.07, 12), swirlMat);
core.name = 'core';
core.rotation.set(deg(90), 0, 0);
vortexSwirl.add(core);
// Animations ('idle') — play them with THREE.AnimationMixer
root.animations = codelsClips(THREE, [
{ target: vortexSwirl, anim: [{ type: 'spin', axis: 'z', speed: 40 }, { type: 'pulse', amount: 0.05, period: 3 }] },
]);
root.traverse((o) => {
if (o.isMesh) {
o.castShadow = true;
o.receiveShadow = true;
}
});
return root;
}
export default createPortal;
Ratings & comments
0 comments
No comments yet. Share how you used this model or what could be better.
Related models
Same category or similar tags









