EmboDi / src /shared /cinema.mjs
xlt99's picture
Upload 180 files
970fc3e verified
Raw History Blame Contribute Delete
12.1 kB
import { CatmullRomCurve3, Vector3, Quaternion, Euler } from "three";
import { bindTrackTrigger, validateTrackTriggers } from "./track-triggers.mjs";
import { clearEntityTargets } from "./entity-targets.mjs";
const finite = (n, min, max) => Number.isFinite(n) && n >= min && n <= max;
const vector = (p, n = 3) => Array.isArray(p) && p.length === n && p.every((v) => finite(v, -100, 100));
const isCamera = (o) => o?.kind === "camera";
const isLight = (o) => o?.kind === "light";
const isRig = (o) => isCamera(o) || isLight(o);
function objectQuaternion(o) {
return o.quaternion ? [...o.quaternion] : new Quaternion().setFromEuler(new Euler(0, o.yaw ?? o.rotation ?? 0, 0)).toArray();
}
function validateCinema(scene) {
if (scene.productionReady !== void 0 && typeof scene.productionReady !== "boolean") throw Error("Invalid setup state");
for (const o of scene.objects) {
if (o.aimTargetId && (![...scene.objects, ...scene.actors || []].some((a) => a.id === o.aimTargetId) || o.aimTargetId === o.id)) throw Error("Invalid camera target");
if (o.quaternion && (!vector(o.quaternion, 4) || Math.abs(Math.hypot(...o.quaternion) - 1) > 0.01)) throw Error("Invalid object pose");
if (o.kind !== void 0 && !["camera", "light"].includes(o.kind)) throw Error("Invalid tool type");
if (isCamera(o) && (!o.camera || !finite(o.camera.fov, 15, 110) || !finite(o.camera.aspect, 0.5, 3))) throw Error("Invalid camera settings");
if (isLight(o) && (!o.light || !["point", "spot"].includes(o.light.type) || !finite(o.light.intensity, 0, 100) || !finite(o.light.range, 0.1, 100) || !finite(o.light.angle, 1, 85) || !/^#[\da-f]{6}$/i.test(o.light.color))) throw Error("Invalid light settings");
if (o.track) {
const t = o.track;
if (!scene.curves?.some((c) => c.id === t.curveId) || !finite(t.duration, 0.25, 120) || !finite(t.delay, 0, 120) || !finite(t.fadeOut, 0, t.duration) || !["fixed", "target", "tangent"].includes(t.orientation)) throw Error("Invalid track or missing curve");
if (t.targetId && ![...scene.objects, ...scene.actors || []].some((a) => a.id === t.targetId)) throw Error("Camera target no longer exists");
}
}
if (scene.objects.filter(isCamera).length > 8 || scene.objects.filter(isLight).length > 8) throw Error("This edition supports up to 8 cameras and 8 lights");
return validateTrackTriggers(scene);
}
function curvePoints(controls, smooth = true) {
if (!Array.isArray(controls) || controls.length < 2 || controls.length > 64 || controls.some((p) => !vector(p))) throw Error("Invalid control points (2–64 required)");
if (controls.every((p) => new Vector3(...p).distanceTo(new Vector3(...controls[0])) < 1e-3)) throw Error("Curve endpoints are too close. Draw a longer path.");
if (!smooth || controls.length === 2) return structuredClone(controls);
return new CatmullRomCurve3(controls.map((p) => new Vector3(...p)), false, "centripetal").getSpacedPoints(Math.min(256, controls.length * 16)).map((p) => p.toArray());
}
function editableControls(curve) {
if (curve.controls) return structuredClone(curve.controls);
const p = curve.points;
if (p.length <= 12) return structuredClone(p);
return Array.from({ length: 12 }, (_, i) => [...p[Math.round(i * (p.length - 1) / 11)]]);
}
function newRig(kind, id, position) {
if (!["camera", "spot", "point", "box"].includes(kind) || !vector(position)) throw Error("Invalid creation parameters");
const o = { id, name: kind === "camera" ? "Camera" : kind === "box" ? "Box" : kind === "spot" ? "Spotlight" : "Point light", group: "Authoring tools", shape: kind === "point" ? "sphere" : "box", position: [...position], size: kind === "box" ? [0.4, 0.4, 0.4] : [0.22, 0.16, 0.28], rotation: 0, roughness: 0.6, metalness: 0, color: kind === "camera" ? "#4098df" : kind === "box" ? "#71b68c" : "#ffd266", editable: true };
if (kind === "camera") {
o.kind = "camera";
o.camera = { fov: 55, aspect: 16 / 9 };
}
if (["spot", "point"].includes(kind)) {
o.kind = "light";
o.light = { type: kind, intensity: 8, range: 12, angle: 35, color: "#fff2da" };
}
return o;
}
function groundCreateCommand(command, point) {
if (!vector(point)) throw Error("Point at visible ground, then press A to place");
const { position: ignored, ...values } = command.values || {};
const position = [...point], body = newRig(command.kind, command.id, position);
position[1] += body.size[1] / 2;
if (!vector(position)) throw Error("Placement is outside the scene");
return { ...command, op: "create", position, values };
}
function applyCinema(scene, command) {
const next = structuredClone(scene), c = command;
next.productionReady = false;
const target = next.objects.find((o) => o.id === c.id);
if (c.op === "create") {
if (next.objects.some((o2) => o2.id === c.id) || !/^[-\w]{1,80}$/.test(c.id)) throw Error("Invalid object ID");
const o = newRig(c.kind, c.id, c.position);
o.name = c.name?.slice(0, 80) || `${o.name} ${next.objects.filter((a) => a.kind === o.kind).length + 1}`;
next.objects.push(o);
if (c.values) return applyCinema(next, { op: "update", id: o.id, values: c.values });
} else if (c.op === "poses") {
if (!Array.isArray(c.poses) || !c.poses.length || c.poses.length > 187) throw Error("No pose changes to save");
for (const p of c.poses) {
const o = [...next.objects, ...next.actors || []].find((a) => a.id === p.id);
if (!o || !vector(p.position) || (!vector(p.quaternion, 4) || Math.hypot(...p.quaternion) < 0.01)) throw Error("Invalid pose target");
if (o.editable !== true && (o.id === "ground" || o.category === "structure" || /^(wall|ceiling)(_|-|$)/.test(o.id))) throw Error("Reference structure is locked.");
o.position = [...p.position];
const q = new Quaternion(...p.quaternion).normalize();
if (o.assetId) o.yaw = new Euler().setFromQuaternion(q, "YXZ").y;
else {
o.quaternion = q.toArray();
o.rotation = new Euler().setFromQuaternion(q, "YXZ").y;
}
}
} else if (c.op === "remove") {
if (!target || target.editable !== true && (target.id === "ground" || target.category === "structure")) throw Error("Select an editable object to delete");
next.objects = clearEntityTargets(next.objects.filter((o) => o.id !== c.id), c.id);
if (next.regions) next.regions = next.regions.filter((r) => (r.objectId || r.doorId) !== c.id);
if (next.floods) next.floods = next.floods.filter((f) => (f.objectId || f.doorId) !== c.id);
} else if (c.op === "update") {
if (!target) throw Error("Select an object, camera or light first");
const p = c.values || {};
if (p.position !== void 0) {
if (!vector(p.position)) throw Error("Invalid position");
target.position = [...p.position];
}
if (p.fov !== void 0) {
if (!isCamera(target)) throw Error("This object is not a camera");
target.camera.fov = p.fov;
}
if (p.lightType !== void 0) {
if (!isLight(target)) throw Error("This object is not a light");
if (!["point", "spot"].includes(p.lightType)) throw Error("Invalid light type");
target.light.type = p.lightType;
target.shape = p.lightType === "point" ? "sphere" : "box";
}
if (p.color !== void 0) {
target.color = p.color;
if (isLight(target)) target.light.color = p.color;
}
for (const key of ["intensity", "range", "angle"]) if (p[key] !== void 0) {
if (!isLight(target)) throw Error("This object is not a light");
target.light[key] = p[key];
}
if (p.curveId !== void 0) {
if (p.curveId === null) delete target.track;
else target.track = { duration: 8, delay: 0, fadeOut: 0, orientation: "fixed", targetId: null, ...target.track, curveId: p.curveId };
}
for (const key of ["duration", "delay", "fadeOut"]) if (p[key] !== void 0) {
if (!target.track) throw Error("Bind a saved curve first");
target.track[key] = p[key];
}
if (p.targetId !== void 0) {
if (target.track) target.track.targetId = p.targetId;
else target.aimTargetId = p.targetId;
}
if (p.orientation !== void 0) {
if (target.track) target.track.orientation = p.orientation;
else if (p.orientation === "fixed") delete target.aimTargetId;
else if (p.orientation !== "target" || !target.aimTargetId) throw Error("Bind a curve before using tangent orientation");
}
} else if (c.op === "regionTrigger") {
bindTrackTrigger(next, c);
if (c.values) return applyCinema(next, { op: "update", id: c.id, values: c.values });
} else if (c.op === "clearTrigger") {
if (!target?.track?.trigger) throw Error("This object has no region trigger");
delete target.track.trigger;
} else if (c.op === "curve") {
const curve = next.curves?.find((p) => p.id === c.id);
if (!curve) throw Error("Curve no longer exists");
curve.rawPoints ||= structuredClone(curve.points);
curve.controls = c.straight ? [[...curve.points[0]], [...curve.points.at(-1)]] : structuredClone(c.controls || editableControls(curve));
curve.smooth = c.smooth ?? true;
curve.points = curvePoints(curve.controls, curve.smooth);
curve.editVersion = (curve.editVersion || 0) + 1;
} else if (c.op === "complete") {
next.productionReady = true;
} else throw Error("Unknown authoring operation");
if (next.triggerRegions) {
const used = new Set(next.objects.map((o) => o.track?.trigger?.regionId));
next.triggerRegions = next.triggerRegions.filter((r) => used.has(r.id));
}
return validateCinema(next);
}
function rigFrame(o, curves, time, targets = []) {
let position = [...o.position], quaternion = objectQuaternion(o), intensity = o.light?.intensity;
const t = o.track, curve = t && curves.find((c) => c.id === t.curveId);
if (!curve) {
const target = targets.find((v) => v.id === o.aimTargetId);
if (target) quaternion = lookAt(position, target, quaternion);
return { id: o.id, position, quaternion, rotation: new Euler().setFromQuaternion(new Quaternion(...quaternion), "YXZ").y, intensity };
}
const points = curve.points;
const lengths = [0];
for (let i2 = 1; i2 < points.length; i2++) lengths.push(lengths[i2 - 1] + new Vector3(...points[i2]).distanceTo(new Vector3(...points[i2 - 1])));
const progress = Math.max(0, Math.min(1, (time - t.delay) / t.duration)), distance = progress * lengths.at(-1);
let i = 1;
while (i < lengths.length - 1 && lengths[i] < distance) i++;
const ratio = (distance - lengths[i - 1]) / (lengths[i] - lengths[i - 1] || 1), a = new Vector3(...points[i - 1]), b = new Vector3(...points[i]);
position = a.clone().lerp(b, ratio).toArray();
let direction = t.orientation === "tangent" ? b.sub(a) : null;
if (t.orientation === "target") {
const target = targets.find((v) => v.id === t.targetId);
if (target) direction = new Vector3(...target.position).add(new Vector3(0, target.assetId ? 1.1 : 0, 0)).sub(new Vector3(...position));
}
if (direction?.lengthSq() > 1e-8) {
direction.normalize();
const yaw = Math.atan2(-direction.x, -direction.z), pitch = Math.asin(Math.max(-1, Math.min(1, direction.y)));
quaternion = new Quaternion().setFromEuler(new Euler(pitch, yaw, 0, "YXZ")).toArray();
}
if (intensity !== void 0 && t.fadeOut > 0) intensity *= Math.max(0, Math.min(1, (t.delay + t.duration - time) / t.fadeOut));
return { id: o.id, position, quaternion, rotation: new Euler().setFromQuaternion(new Quaternion(...quaternion), "YXZ").y, intensity };
}
function lookAt(position, target, fallback) {
const d = new Vector3(...target.position).add(new Vector3(0, target.assetId ? 1.1 : 0, 0)).sub(new Vector3(...position));
if (d.lengthSq() < 1e-8) return fallback;
d.normalize();
return new Quaternion().setFromEuler(new Euler(Math.asin(Math.max(-1, Math.min(1, d.y))), Math.atan2(-d.x, -d.z), 0, "YXZ")).toArray();
}
export {
applyCinema,
curvePoints,
editableControls,
groundCreateCommand,
isCamera,
isLight,
isRig,
newRig,
objectQuaternion,
rigFrame,
validateCinema
};