EmboDi / src /shared /scan-structure.mjs
xlt99's picture
Upload 180 files
970fc3e verified
Raw History Blame Contribute Delete
9.81 kB
import { roomFromPlanes } from "./room-spatial.mjs";
import { validateScene } from "./scene.mjs";
const distance = (a, b) => Math.hypot(a[0] - b[0], a[2] - b[2]);
const angle = (a) => Math.atan2(Math.sin(a), Math.cos(a));
const area = (p) => Math.abs(p.points.reduce((s, v, i) => {
const q = p.points[(i + 1) % p.points.length];
return s + v[0] * q[2] - q[0] * v[2];
}, 0) / 2);
function validateScanPlanes(input) {
if (!Array.isArray(input) || input.length < 3 || input.length > 500) throw new Error("Between 3 and 500 scanned planes are required");
return input.map((p, index) => {
if (!p || !["horizontal", "vertical"].includes(p.orientation) || typeof p.label !== "string" || p.label.length > 100 || !Array.isArray(p.points) || p.points.length < 3 || p.points.length > 128 || p.points.some((v) => !Array.isArray(v) || v.length !== 3 || v.some((n) => !Number.isFinite(n) || Math.abs(n) > 100))) throw new Error("Invalid scanned plane data");
const points = p.points.map((v) => [...v]);
if (distance(points[0], points.at(-1)) < 1e-5 && Math.abs(points[0][1] - points.at(-1)[1]) < 1e-5) points.pop();
if (points.length < 3) throw new Error("Scanned plane corners are missing");
return { index, label: p.label, orientation: p.orientation, points };
});
}
function planeBox(p) {
let direction = [1, 0, 0], longest = 0;
for (let i = 0; i < p.points.length; i++) {
const a = p.points[i], b = p.points[(i + 1) % p.points.length], d = distance(a, b);
if (d > longest) {
longest = d;
direction = [(b[0] - a[0]) / d, 0, (b[2] - a[2]) / d];
}
}
if (longest < 0.02) return null;
const x = direction, z = [-x[2], 0, x[0]], bounds = (axis) => {
const vs = p.points.map((v) => v[0] * axis[0] + v[2] * axis[2]);
return [Math.min(...vs), Math.max(...vs)];
};
const bx = bounds(x), bz = bounds(z), cx = (bx[0] + bx[1]) / 2, cz = (bz[0] + bz[1]) / 2, ys = p.points.map((v) => v[1]);
return { position: [cx * x[0] + cz * z[0], (Math.min(...ys) + Math.max(...ys)) / 2, cx * x[2] + cz * z[2]], size: [bx[1] - bx[0], Math.max(...ys) - Math.min(...ys), bz[1] - bz[0]], rotation: Math.atan2(-x[2], x[0]) };
}
function scanContentAnchors(input, alignment, scene) {
const c = Math.cos(alignment.yaw), s = Math.sin(alignment.yaw), floors = scene.objects.filter((o) => o.id === "ground" || o.group === "scan-floors");
const local = (p) => {
const x = p[0] - alignment.origin[0], z = p[2] - alignment.origin[2];
return [c * x - s * z, p[1] - alignment.origin[1], s * x + c * z];
};
const anchors = [];
for (const p of validateScanPlanes(input)) {
const kind = p.label.toLowerCase();
if (!["table", "shelf", "couch", "window"].includes(kind)) continue;
if (kind === "window" ? p.orientation !== "vertical" : p.orientation !== "horizontal") continue;
const box = planeBox({ ...p, points: p.points.map(local) });
if (!box) continue;
if (kind === "window") box.size[2] = 0.12;
if (box.size[0] < 0.15 || (kind === "window" ? box.size[1] < 0.15 : box.size[2] < 0.15)) continue;
const below = floors.filter((f) => {
const dx = box.position[0] - f.position[0], dz = box.position[2] - f.position[2], fc = Math.cos(f.rotation), fs = Math.sin(f.rotation);
return Math.abs(fc * dx - fs * dz) <= f.size[0] / 2 + 0.05 && Math.abs(fs * dx + fc * dz) <= f.size[2] / 2 + 0.05 && f.position[1] + f.size[1] / 2 < box.position[1] - 0.1;
}).sort((a, b) => b.position[1] + b.size[1] / 2 - (a.position[1] + a.size[1] / 2));
if (!below.length) continue;
const floor = below[0], floorY = floor.position[1] + floor.size[1] / 2;
if (kind !== "window" && box.position[1] - floorY > 3.5) continue;
if (anchors.some((a) => a.kind === kind && distance(a.position, box.position) < 0.08 && Math.abs(a.position[1] - box.position[1]) < 0.08 && Math.abs(a.size[0] - box.size[0]) < 0.1 && Math.abs(a.size[2] - box.size[2]) < 0.1)) continue;
anchors.push({ id: `surface-${p.index}`, kind, position: box.position, size: box.size, rotation: angle(box.rotation), floorId: floor.id, floorY, source: "quest-plane" });
}
return anchors;
}
function wallParts(wall, doors) {
const c = Math.cos(wall.rotation), s = Math.sin(wall.rotation), bottom = wall.position[1] - wall.size[1] / 2, top = bottom + wall.size[1];
const holes = doors.map((door) => {
const dx = door.position[0] - wall.position[0], dz = door.position[2] - wall.position[2];
const x = c * dx - s * dz, z = s * dx + c * dz;
if (Math.abs(Math.cos(door.rotation - wall.rotation)) < 0.98 || Math.abs(z) > 0.14) return null;
const lo = Math.max(-wall.size[0] / 2, x - door.size[0] / 2), hi = Math.min(wall.size[0] / 2, x + door.size[0] / 2), y0 = Math.max(bottom, door.position[1] - door.size[1] / 2), y1 = Math.min(top, door.position[1] + door.size[1] / 2);
return hi - lo > 0.04 && y1 - y0 > 0.3 ? { lo, hi, y0, y1 } : null;
}).filter(Boolean);
const xs = [.../* @__PURE__ */ new Set([-wall.size[0] / 2, wall.size[0] / 2, ...holes.flatMap((h) => [h.lo, h.hi])])].sort((a, b) => a - b), parts = [];
function add(lo, hi, y0, y1) {
if (hi - lo < 0.02 || y1 - y0 < 0.02) return;
const x = (lo + hi) / 2;
parts.push({ ...wall, position: [wall.position[0] + c * x, (y0 + y1) / 2, wall.position[2] - s * x], size: [hi - lo, y1 - y0, 0.1] });
}
for (let i = 0; i < xs.length - 1; i++) {
const lo = xs[i], hi = xs[i + 1], middle = (lo + hi) / 2, intervals = holes.filter((h) => middle > h.lo && middle < h.hi).sort((a, b) => a.y0 - b.y0);
let y = bottom;
for (const hole of intervals) {
add(lo, hi, y, hole.y0);
y = Math.max(y, hole.y1);
}
add(lo, hi, y, top);
}
return parts;
}
function scanStructure(input, { preferredAspect = 1, title = "Corridor and lab 路 Scan structure preview" } = {}) {
const planes = validateScanPlanes(input), candidate = roomFromPlanes(planes, preferredAspect);
if (!candidate) throw new Error("A usable floor and ceiling are required. Complete the Quest room scan first.");
const c = Math.cos(candidate.yaw), s = Math.sin(candidate.yaw);
const toLocal = (p) => {
const x = p[0] - candidate.origin[0], z = p[2] - candidate.origin[2];
return [c * x - s * z, p[1] - candidate.origin[1], s * x + c * z];
};
const local = planes.map((p) => ({ ...p, points: p.points.map(toLocal) }));
const floors = local.filter((p) => p.orientation === "horizontal" && /^floor$/i.test(p.label)).sort((a, b) => area(b) - area(a));
const ceilings = local.filter((p) => p.orientation === "horizontal" && /^ceiling$/i.test(p.label));
const doors = [];
for (const p of local.filter((p2) => p2.orientation === "vertical" && /^door$/i.test(p2.label))) {
const box = planeBox(p);
if (!box || box.size[1] < 0.3) continue;
box.size[2] = 0.05;
if (doors.some((d) => distance(d.position, box.position) < 0.18 && Math.abs(Math.cos(d.rotation - box.rotation)) > 0.98 && Math.abs(d.size[0] - box.size[0]) < 0.18)) continue;
doors.push({ ...box, index: p.index });
}
const objects = [], add = (id, name, group, box, color = "#ccd2dc") => objects.push({ id, name, group, category: "structure", shape: "box", ...box, rotation: angle(box.rotation), color, roughness: 0.95, metalness: 0 });
floors.forEach((p, i) => {
const box = planeBox(p);
if (!box) return;
if (i === 0) {
box.position = [0, -0.04, 0];
box.size = [candidate.metrics.width, 0.08, candidate.metrics.depth];
box.rotation = 0;
} else {
box.position[1] -= 0.04;
box.size[1] = 0.08;
}
add(i === 0 ? "ground" : `scan-floor-${p.index}`, `Scanned floor ${p.index} 路 Bounding rectangle`, "scan-floors", box, ["#91b4cd", "#b1a5cf", "#9fc9b7"][i % 3]);
});
ceilings.sort((a, b) => area(b) - area(a)).forEach((p, i) => {
const box = planeBox(p);
if (!box) return;
box.position[1] += 0.04;
box.size[1] = 0.08;
add(i === 0 ? "ceiling" : `scan-ceiling-${p.index}`, `Scanned ceiling ${p.index}`, "ceiling", box);
});
let wallCount = 0;
for (const p of local.filter((p2) => p2.orientation === "vertical" && /^wall$/i.test(p2.label))) {
const box = planeBox(p);
if (!box || box.size[1] < 0.3) continue;
wallCount++;
wallParts(box, doors).forEach((part, i) => add(`scan-wall-${p.index}-${i}`, `Scanned wall ${p.index} 路 ${i + 1}`, "walls", part));
}
if (!wallCount) throw new Error("Scan returned no walls. A floor outline alone cannot define the corridor.");
doors.forEach((d, i) => add(`scan-door-${d.index}`, `Door ${i + 1} 路 Static scanned surface`, "scan-doors", { position: d.position, size: d.size, rotation: d.rotation }, "#e4ad67"));
const warnings = ["Floors and ceilings show scanned bounding rectangles, not verified walkable areas.", "Adjacent scans may contain duplicate or truncated walls. Check connections in the room; they are not automatically removed or completed.", "Doors show static scanned positions, not their live open or closed state."];
const scene = validateScene({ title, description: `Uses ${floors.length} floors, ${ceilings.length} ceilings, ${wallCount} walls and ${doors.length} deduplicated doors returned by Quest, preserving their relative positions in meters. ${warnings.join("")}`, room: candidate.metrics, objects, actors: [], scanStructure: { schema: "vrbuild-scan-structure/1", floorCount: floors.length, wallCount, doorCount: doors.length, referenceFloorIndex: floors[0].index, warnings } });
scene.scanStructure.contentAnchors = scanContentAnchors(input, candidate, scene);
return { scene, alignment: candidate, counts: { planes: planes.length, floors: floors.length, ceilings: ceilings.length, walls: wallCount, doors: doors.length } };
}
export {
planeBox,
scanContentAnchors,
scanStructure,
validateScanPlanes
};