EmboDi / src /shared /flood.mjs
xlt99's picture
Upload 180 files
970fc3e verified
Raw History Blame Contribute Delete
7.49 kB
import { validateRegions, polygonContains, regionObjectId, isRegionObject, isSourceRegion } from "./interaction-regions.mjs";
import { isDoor } from "./doors.mjs";
const number = { type: "number" };
const string = { type: "string" };
const object = (properties) => ({ type: "object", additionalProperties: false, properties, required: Object.keys(properties) });
const floodSchema = object({ reply: string, plan: { anyOf: [object({ triggerRegionId: string, dwellSeconds: number, sources: { type: "array", items: object({ regionId: string, speed: number, amount: number, reach: number, angle: number }) }, duration: number, color: string }), { type: "null" }] } });
const bounded = (n, min, max) => Number.isFinite(n) && n >= min && n <= max;
function validateFloods(scene) {
if (scene.regions !== void 0) validateRegions(scene.regions, scene);
if (scene.floods === void 0) return scene;
if (!Array.isArray(scene.floods) || scene.floods.length > 8) throw Error("At most 8 flow interactions are supported");
const ids = /* @__PURE__ */ new Set(), objects = /* @__PURE__ */ new Set();
for (const f of scene.floods) {
const ownerId = regionObjectId(f), owner = scene.objects.find((o) => o.id === ownerId);
if (!f || f.schema !== "vrbuild-flood/1" || typeof f.id !== "string" || !/^[\w-]{1,80}$/.test(f.id) || ids.has(f.id) || objects.has(ownerId) || !(isRegionObject(owner) || isDoor(owner)) || f.objectId && f.doorId && f.objectId !== f.doorId) throw Error("Invalid flow ID or object target.");
ids.add(f.id);
objects.add(ownerId);
const zone = scene.regions?.find((r) => r.id === f.trigger?.regionId);
if (f.trigger?.type !== "region-dwell" || f.trigger.subject !== "user" || f.trigger.repeat !== "once-per-preview" || !zone || zone.surface !== "floor" || regionObjectId(zone) !== ownerId || !bounded(f.trigger.seconds, 0.1, 60)) throw Error("Flow needs this object's floor region and a 0.1–60 second viewer-dwell condition.");
if (!bounded(f.duration, 0.5, 30) || typeof f.color !== "string" || !/^#[\da-f]{6}$/i.test(f.color)) throw Error("Invalid flow duration or color");
if (!Array.isArray(f.sources) || !f.sources.length || f.sources.length > 6) throw Error("Select 1–6 surface source regions.");
const sources = /* @__PURE__ */ new Set();
for (const source of f.sources) {
const region = scene.regions?.find((r) => r.id === source.regionId);
if (!region || !isSourceRegion(region) || regionObjectId(region) !== ownerId || sources.has(source.regionId)) throw Error("Flow sources must belong to the selected object.");
sources.add(source.regionId);
if (!bounded(source.speed, 0.1, 3) || !bounded(source.amount, 0.1, 3) || !bounded(source.reach, 0.2, 6) || !bounded(source.angle, -150, 150)) throw Error("Flow speed, amount, reach or direction is outside the supported range");
}
}
return scene;
}
function compileFlood(result, scene, objectId) {
if (typeof result?.reply !== "string" || !result.reply.trim() || result.reply.length > 3e3) throw Error("Invalid Flood Agent reply");
if (result.plan === null) return null;
const owner = scene.objects.find((o) => o.id === objectId);
if (!(isRegionObject(owner) || isDoor(owner))) throw Error("Select one editable object to author a flow effect.");
const previous = scene.floods?.find((f) => regionObjectId(f) === objectId), p = result.plan;
if (!p || !Array.isArray(p.sources)) throw Error("Invalid flow plan");
const plan = { schema: "vrbuild-flood/1", id: previous?.id || "flood-" + objectId, objectId, ...isDoor(owner) ? { doorId: objectId } : {}, trigger: { type: "region-dwell", regionId: p.triggerRegionId, subject: "user", seconds: p.dwellSeconds, repeat: "once-per-preview" }, sources: p.sources.map((s) => ({ regionId: s.regionId, speed: s.speed, amount: s.amount, reach: s.reach, angle: s.angle })), duration: p.duration, color: p.color };
if (previous && (previous.trigger.regionId !== plan.trigger.regionId || JSON.stringify(previous.sources.map((s) => s.regionId).sort()) !== JSON.stringify(plan.sources.map((s) => s.regionId).sort()))) throw Error("Preserve region bindings when adjusting an existing effect. Redraw the sketch to change its regions.");
validateFloods({ ...scene, floods: [...(scene.floods || []).filter((f) => regionObjectId(f) !== objectId), plan] });
return plan;
}
function applyFlood(scene, result, ids) {
if (!Array.isArray(ids) || ids.length !== 1 || ids[0] !== regionObjectId(result.plan)) throw Error("Flow must modify only the selected object.");
const next = structuredClone(scene);
next.floods = [...(next.floods || []).filter((f) => regionObjectId(f) !== regionObjectId(result.plan)), structuredClone(result.plan)];
return validateFloods(next);
}
function createFloodRuntime() {
let scene = null, states = [], armed = false, last = null, enabledIds = null, paused = false;
const reset = () => {
states = (scene?.floods || []).map((f) => ({ id: f.id, dwell: 0, inside: false, fired: false, time: 0, events: 0 }));
last = null;
};
return {
sync(next) {
scene = next;
armed = false;
paused = false;
enabledIds = null;
reset();
},
start(ids = null) {
enabledIds = ids === null ? null : new Set(ids);
armed = ids === null || ids.length > 0;
paused = false;
reset();
},
stop() {
armed = false;
paused = false;
reset();
},
pause() {
paused = true;
last = null;
},
resume() {
paused = false;
last = null;
},
tick({ now, position, active = true, tracked = true }) {
const valid = armed && active && tracked && Array.isArray(position) && position.length === 3 && position.every(Number.isFinite) && Number.isFinite(now);
if (paused) {
last = null;
if (!valid) for (const s of states) {
s.dwell = 0;
s.inside = false;
}
return this.snapshot(valid);
}
const dt = last === null ? 0 : now - last;
last = valid ? now : null;
const interrupted = !valid || dt < 0 || dt > 0.25;
for (const state of states) {
if (enabledIds && !enabledIds.has(state.id)) continue;
const f = scene.floods.find((f2) => f2.id === state.id), zone = scene.regions.find((r) => r.id === f.trigger.regionId);
if (interrupted) {
state.dwell = 0;
state.inside = false;
continue;
}
if (state.fired) {
state.time = Math.min(f.duration, state.time + dt);
continue;
}
const height = position[1] - zone.floorY;
const inside = height >= 0.15 && height <= 3 && polygonContains(zone.points, [position[0], position[2]], state.inside ? 0.03 : 0);
if (!inside) {
state.dwell = 0;
state.inside = false;
continue;
}
if (state.inside) state.dwell += dt;
else state.dwell = 0;
state.inside = true;
if (state.dwell > f.trigger.seconds) {
state.fired = true;
state.time = 0;
state.events++;
}
}
return this.snapshot(valid && !interrupted);
},
snapshot(visible = armed) {
return { armed, paused, visible, activeIds: enabledIds ? [...enabledIds] : (scene?.floods || []).map((f) => f.id), states: states.map((s) => ({ ...s })) };
}
};
}
export {
applyFlood,
compileFlood,
createFloodRuntime,
floodSchema,
validateFloods
};