File size: 9,808 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
139
140
141
142
143
144
145
146
147
148
149
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
};