diff --git a/src/control.html b/src/control.html
index 03d5cd5..450c36d 100644
--- a/src/control.html
+++ b/src/control.html
@@ -169,6 +169,10 @@
con la camera lo comandi in “Interattivo Avatar”.
I miei modelli
+ Apertura braccia 0°
+
+ Se durante i balli le braccia trapassano il corpo (modelli più larghi
+ del manichino standard), aumenta finché sono libere. Salvato per ogni modello.
Sfondo
Gradiente
diff --git a/src/control.js b/src/control.js
index 3c25adb..cf018b3 100644
--- a/src/control.js
+++ b/src/control.js
@@ -162,6 +162,7 @@ function glbSend() {
glbAnimFiles.forEach(p => send({ type: 'animAdd', path: p }));
send({ type: 'glbAnims', names: glbClipSel });
send({ type: 'modelBpm', bpm: glbBpm });
+ spreadSend();
}
function renderGlbClips(names) {
const box = $('#glb-anims');
@@ -215,6 +216,8 @@ function renderGlbModels() {
localStorage.setItem('glb3d', glbPath);
send({ type: 'glb', path: glbPath });
glbLabel(mo.name);
+ spreadUi();
+ spreadSend();
renderGlbModels();
});
const del = document.createElement('button');
@@ -231,6 +234,16 @@ function renderGlbModels() {
box.appendChild(row);
});
}
+// Arm spread, saved per model path (chunky models need more room).
+let glbSpreadMap = {};
+try { glbSpreadMap = JSON.parse(localStorage.getItem('glbspread') || '{}'); } catch (e) {}
+function spreadFor(p) { return glbSpreadMap[p] || 0; }
+function spreadUi() {
+ const v = spreadFor(glbPath);
+ $('#glb-spread').value = v;
+ $('#glb-spread-val').textContent = v + '°';
+}
+function spreadSend() { send({ type: 'modelSpread', deg: spreadFor(glbPath) }); }
function glbLabel(name) {
$('#glb-name').textContent = name
? '🧊 ' + name + ' — attiva la famiglia “Modello 3D” per vederlo.'
@@ -258,6 +271,13 @@ $('#glb-bg').addEventListener('change', (e) => {
send({ type: 'modelBg', mode: glbBg });
});
renderGlbClips([]);
+spreadUi();
+$('#glb-spread').addEventListener('input', (e) => {
+ const v = parseInt(e.target.value, 10) || 0;
+ if (glbPath) { glbSpreadMap[glbPath] = v; localStorage.setItem('glbspread', JSON.stringify(glbSpreadMap)); }
+ $('#glb-spread-val').textContent = v + '°';
+ send({ type: 'modelSpread', deg: v });
+});
$('#btn-anim-load').addEventListener('click', () => $('#anim-input').click());
$('#anim-input').addEventListener('change', async (e) => {
for (const f of [...e.target.files]) {
@@ -357,6 +377,7 @@ $('#glb-input').addEventListener('change', async (e) => {
glbSend();
glbLabel(f.name.replace(/\.fbx$/i, '.glb'));
addGlbModel(f.name.replace(/\.fbx$/i, '.glb'), p);
+ spreadUi();
});
// --- Effect sequence (playlist of effects with auto-cycle) ---
diff --git a/src/model3d.js b/src/model3d.js
index e32bd85..13e3611 100644
--- a/src/model3d.js
+++ b/src/model3d.js
@@ -476,6 +476,7 @@ class ModelSim {
this.libAnims = []; // library clips bound to the current skeleton
this.clipFilter = null; // Set of allowed clip names (empty/null = all)
this.manualBpm = 0; // 0 = follow the detected beat
+ this.armSpread = 0; // radians: swing the arms away from the torso
this._upload(torusKnot());
}
@@ -852,6 +853,20 @@ class ModelSim {
}
}
}
+ // arm spread: clips authored for the standard mannequin make chunky
+ // models self-intersect — rotate the upper arms outward (world Z,
+ // the character faces the camera) by the per-model amount
+ if (anim && this.armSpread) {
+ const b2 = base(n.name);
+ if (b2 === 'LeftArm' || b2 === 'RightArm') {
+ const sa = b2 === 'LeftArm' ? this.armSpread : -this.armSpread;
+ const cs = Math.cos(sa), sn = Math.sin(sa);
+ const Rz = [cs, sn, 0, -sn, cs, 0, 0, 0, 1];
+ const pr = parentWorld ? m4Rot3(parentWorld) : [1,0,0, 0,1,0, 0,0,1];
+ const prT = [pr[0],pr[3],pr[6], pr[1],pr[4],pr[7], pr[2],pr[5],pr[8]];
+ r3 = m3Mul(m3Mul(m3Mul(prT, Rz), pr), r3);
+ }
+ }
const s = n.s;
local = m4FromM3T([r3[0]*s[0], r3[1]*s[0], r3[2]*s[0],
r3[3]*s[1], r3[4]*s[1], r3[5]*s[1],
diff --git a/src/output.js b/src/output.js
index 5744b30..2b8ae58 100644
--- a/src/output.js
+++ b/src/output.js
@@ -323,6 +323,7 @@ djv.onControl(async (m) => {
case 'modelBg': viz.modelBg = m.mode || 'gradient'; break;
case 'glbAnims': viz.setClipFilter(m.names || []); break;
case 'modelBpm': viz.setManualBpm(m.bpm || 0); break;
+ case 'modelSpread': viz.setArmSpread(m.deg || 0); break;
case 'animAdd':
// extra animation GLB for the dance library
try {
diff --git a/src/visualizer.js b/src/visualizer.js
index dd1f190..4396c39 100644
--- a/src/visualizer.js
+++ b/src/visualizer.js
@@ -97,6 +97,7 @@ class Visualizer {
});
if (this._clipFilter) this.model3d.clipFilter = this._clipFilter;
if (this._manualBpm) this.model3d.manualBpm = this._manualBpm;
+ if (this._armSpread) this.model3d.armSpread = this._armSpread;
}
if (this._pendingGlb) { this.model3d.setModel(this._pendingGlb); this._pendingGlb = null; }
return this.model3d;
@@ -130,6 +131,11 @@ class Visualizer {
if (this.model3d) this.model3d.manualBpm = this._manualBpm;
}
+ setArmSpread(deg) {
+ this._armSpread = (deg || 0)*Math.PI/180;
+ if (this.model3d) this.model3d.armSpread = this._armSpread;
+ }
+
// Upload a custom source (HTMLImageElement / HTMLCanvasElement) for the
// SVG/Image effect family. Flipped on the Y axis to match screen space.
setTexture(source) {