File size: 7,485 Bytes
970fc3e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 | 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
};
|