@grupolapa/validate-delivery 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -2
- package/bin/validate-delivery.mjs +2586 -0
- package/package.json +24 -4
|
@@ -0,0 +1,2586 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli/validate-delivery.ts
|
|
4
|
+
import { readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
|
|
7
|
+
// src/contract/report.ts
|
|
8
|
+
var levelOrder = { error: 0, observacion: 1 };
|
|
9
|
+
var compareText = (a, b) => (a ?? "").localeCompare(b ?? "", "es", { numeric: true });
|
|
10
|
+
function sortFindings(findings) {
|
|
11
|
+
return [...findings].sort(
|
|
12
|
+
(a, b) => levelOrder[a.nivel] - levelOrder[b.nivel] || compareText(a.regla, b.regla) || compareText(a.archivo, b.archivo) || compareText(a.hoja, b.hoja) || (a.fila ?? 0) - (b.fila ?? 0) || compareText(a.guid, b.guid) || compareText(a.mensaje, b.mensaje)
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
function location(finding) {
|
|
16
|
+
const parts = [
|
|
17
|
+
finding.archivo,
|
|
18
|
+
finding.hoja && finding.fila ? `${finding.hoja}!${finding.fila}` : finding.hoja,
|
|
19
|
+
finding.guid ? `GUID ${finding.guid}` : void 0,
|
|
20
|
+
finding.elementId ? `Element ${finding.elementId}` : void 0
|
|
21
|
+
].filter(Boolean);
|
|
22
|
+
return parts.length ? `${parts.join(" \xB7 ")}: ` : "";
|
|
23
|
+
}
|
|
24
|
+
function groupByRule(findings) {
|
|
25
|
+
const groups = /* @__PURE__ */ new Map();
|
|
26
|
+
for (const finding of findings) {
|
|
27
|
+
const list3 = groups.get(finding.regla) ?? [];
|
|
28
|
+
list3.push(finding);
|
|
29
|
+
groups.set(finding.regla, list3);
|
|
30
|
+
}
|
|
31
|
+
return groups;
|
|
32
|
+
}
|
|
33
|
+
function formatReport(report, options = {}) {
|
|
34
|
+
const examples = options.examples ?? 5;
|
|
35
|
+
const { entrega } = report;
|
|
36
|
+
const lines = [];
|
|
37
|
+
const title = [entrega.proyecto, entrega.revision].filter(Boolean).join(" \xB7 ") || "Entrega";
|
|
38
|
+
lines.push(`${title}${entrega.fecha ? ` (${entrega.fecha})` : ""}`);
|
|
39
|
+
lines.push(`Validador ${report.validatorVersion} \xB7 pol\xEDtica ${report.policyVersion}`);
|
|
40
|
+
if (entrega.huella) lines.push(`Huella de la entrega: ${entrega.huella.slice(0, 16)}\u2026`);
|
|
41
|
+
if (entrega.archivos.length) {
|
|
42
|
+
lines.push("");
|
|
43
|
+
for (const file of entrega.archivos)
|
|
44
|
+
lines.push(` ${file.rol.padEnd(10)} ${file.archivo} (${formatBytes(file.bytes)})`);
|
|
45
|
+
}
|
|
46
|
+
lines.push("", `Errores (${report.errores.length})`);
|
|
47
|
+
for (const finding of report.errores)
|
|
48
|
+
lines.push(` \u2716 [${finding.regla}] ${location(finding)}${finding.mensaje}`);
|
|
49
|
+
lines.push("", `Observaciones (${report.observaciones.length})`);
|
|
50
|
+
for (const [rule, list3] of groupByRule(report.observaciones)) {
|
|
51
|
+
lines.push(` \u26A0 [${rule}] ${list3.length}`);
|
|
52
|
+
for (const finding of list3.slice(0, examples))
|
|
53
|
+
lines.push(` ${location(finding)}${finding.mensaje}`);
|
|
54
|
+
if (list3.length > examples) lines.push(` \u2026 y ${list3.length - examples} m\xE1s`);
|
|
55
|
+
}
|
|
56
|
+
const metrics = Object.entries(report.metricas);
|
|
57
|
+
if (metrics.length) {
|
|
58
|
+
lines.push("", "M\xE9tricas");
|
|
59
|
+
for (const [key, value2] of metrics) lines.push(` ${key}: ${value2}`);
|
|
60
|
+
}
|
|
61
|
+
lines.push("", `Veredicto: ${report.veredicto.toUpperCase()}`);
|
|
62
|
+
return lines.join("\n");
|
|
63
|
+
}
|
|
64
|
+
function formatBytes(bytes) {
|
|
65
|
+
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MiB`;
|
|
66
|
+
if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KiB`;
|
|
67
|
+
return `${bytes} B`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// src/contract/validate.ts
|
|
71
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
72
|
+
|
|
73
|
+
// src/processing/houses/catalog.mjs
|
|
74
|
+
import { unzipSync, strFromU8 } from "fflate";
|
|
75
|
+
import { XMLParser } from "fast-xml-parser";
|
|
76
|
+
|
|
77
|
+
// src/processing/model/manifest.mjs
|
|
78
|
+
import { createHash } from "node:crypto";
|
|
79
|
+
import { PDFDocument, PDFName, decodePDFRawStream } from "pdf-lib";
|
|
80
|
+
|
|
81
|
+
// src/processing/model/approvals.mjs
|
|
82
|
+
var commonFields = ["kind", "decision", "globalId", "elementId", "modelSha256", "reviewer", "date", "reason"];
|
|
83
|
+
var relationFields = [...commonFields, "conceptId", "catalogSha256"];
|
|
84
|
+
var graphicFields = [...commonFields, "viewId", "page", "pdfSha256", "representationSha256"];
|
|
85
|
+
|
|
86
|
+
// src/processing/model/manifest.mjs
|
|
87
|
+
var hash = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
88
|
+
function verifyMetadata(text, revision, format) {
|
|
89
|
+
const input = format === "ifc" ? text.slice(0, text.indexOf("ENDSEC;")) : text;
|
|
90
|
+
for (const [key, pdf, ifc] of [["contentId", "content_id", "ContentGUID"], ["versionId", "version_id", "VersionGUID"]]) {
|
|
91
|
+
const pattern = format === "pdf" ? new RegExp(`<pdfx:${pdf}>([^<]+)</pdfx:${pdf}>`, "g") : new RegExp(`${ifc}: ([a-fA-F0-9-]+)`, "g");
|
|
92
|
+
const values = [...input.matchAll(pattern)].map((match) => match[1]);
|
|
93
|
+
if (values.length !== 1 || values[0] !== revision[key]) throw new Error(`Uncoordinated ${format} ${key}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async function verifyPdfMetadata(bytes, revision) {
|
|
97
|
+
const pdf = await PDFDocument.load(bytes);
|
|
98
|
+
const metadata = pdf.catalog.lookup(PDFName.of("Metadata"));
|
|
99
|
+
if (!metadata) throw new Error("PDF has no verifiable Revit metadata");
|
|
100
|
+
verifyMetadata(new TextDecoder().decode(decodePDFRawStream(metadata).decode()), revision, "pdf");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// src/processing/houses/catalog.mjs
|
|
104
|
+
var list = (value2) => value2 == null ? [] : Array.isArray(value2) ? value2 : [value2];
|
|
105
|
+
var xml = new XMLParser({ ignoreAttributes: false, parseTagValue: false, trimValues: false });
|
|
106
|
+
var plain = (value2) => typeof value2 === "string" ? value2 : value2?.["#text"] ?? "";
|
|
107
|
+
var rich = (value2) => value2?.t != null ? plain(value2.t) : list(value2?.r).map((run) => plain(run.t)).join("");
|
|
108
|
+
var normalized = (value2) => String(value2 ?? "").normalize("NFD").replace(/[\u0300-\u036f]/g, "").trim().toUpperCase().replace(/[.\s_]+/g, "");
|
|
109
|
+
var aliases = { code: ["CODIGO", "CODE"], description: ["CONCEPTO", "DESCRIPCION", "DESCRIPTION"], unit: ["UNIDAD", "UNIT"], quantity: ["CANTIDAD", "QUANTITY"], unitPrice: ["PUNITARIO", "PRECIOUNITARIO", "UNITPRICE"], amount: ["IMPORTE", "AMOUNT"] };
|
|
110
|
+
var groupCode = /^\d+(?:\.\d+)*$/;
|
|
111
|
+
function sheetsOf(bytes) {
|
|
112
|
+
let total = 0;
|
|
113
|
+
const zip = unzipSync(bytes, { filter(entry) {
|
|
114
|
+
total += entry.originalSize;
|
|
115
|
+
if (entry.originalSize > 32 * 1024 * 1024 || total > 64 * 1024 * 1024) throw new Error("El cat\xE1logo XLSX excede el tama\xF1o admitido (64 MB descomprimido).");
|
|
116
|
+
return entry.name.startsWith("xl/");
|
|
117
|
+
} });
|
|
118
|
+
const parse = (path) => {
|
|
119
|
+
if (!zip[path]) throw new Error(`Falta parte XLSX: ${path}`);
|
|
120
|
+
const content = strFromU8(zip[path]);
|
|
121
|
+
if (/<!DOCTYPE|<!ENTITY/i.test(content)) throw new Error("Declaraciones XML externas o entidades personalizadas no admitidas en XLSX.");
|
|
122
|
+
return xml.parse(content);
|
|
123
|
+
};
|
|
124
|
+
const strings = zip["xl/sharedStrings.xml"] ? list(parse("xl/sharedStrings.xml").sst.si).map(rich) : [];
|
|
125
|
+
const relationships = list(parse("xl/_rels/workbook.xml.rels").Relationships.Relationship);
|
|
126
|
+
return list(parse("xl/workbook.xml").workbook.sheets.sheet).map((sheet) => {
|
|
127
|
+
const rel = relationships.find((item) => item["@_Id"] === sheet["@_r:id"]);
|
|
128
|
+
const target = rel?.["@_Target"]?.replace(/^\/xl\//, "");
|
|
129
|
+
if (!/^worksheets\/[^/]+\.xml$/.test(target ?? "") || rel?.["@_TargetMode"] === "External") throw new Error("Referencia de hoja XLSX no admitida.");
|
|
130
|
+
return { sheet: sheet["@_name"], rows: list(parse(`xl/${target}`).worksheet.sheetData?.row).map((row) => ({
|
|
131
|
+
row: Number(row["@_r"]),
|
|
132
|
+
cells: Object.fromEntries(list(row.c).map((cell) => {
|
|
133
|
+
let value2 = cell.v ?? null;
|
|
134
|
+
if (cell["@_t"] === "s") {
|
|
135
|
+
value2 = strings[Number(value2)];
|
|
136
|
+
if (value2 === void 0) throw new Error(`\xCDndice de texto XLSX inv\xE1lido en ${cell["@_r"]}.`);
|
|
137
|
+
} else if (cell["@_t"] === "inlineStr") value2 = rich(cell.is);
|
|
138
|
+
else if (value2 != null && !["str", "e"].includes(cell["@_t"])) value2 = Number(value2);
|
|
139
|
+
return [cell["@_r"].replace(/\d+$/, ""), { value: value2, formula: cell.f == null ? null : plain(cell.f), error: ["e", "b"].includes(cell["@_t"]) }];
|
|
140
|
+
}))
|
|
141
|
+
})) };
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
function columnsOf(row) {
|
|
145
|
+
const columns = {};
|
|
146
|
+
for (const [field, labels3] of Object.entries(aliases)) {
|
|
147
|
+
const found = Object.entries(row.cells).filter(([, cell]) => labels3.includes(normalized(cell.value)));
|
|
148
|
+
if (found.length > 1) throw new Error(`Encabezado duplicado: ${field}, fila ${row.row}`);
|
|
149
|
+
if (found[0]) columns[field] = found[0][0];
|
|
150
|
+
}
|
|
151
|
+
return ["code", "description", "unit", "quantity"].every((field) => columns[field]) ? columns : null;
|
|
152
|
+
}
|
|
153
|
+
function numberField(cell, field, row, optional = false) {
|
|
154
|
+
if (optional && (cell?.value == null || cell.value === "")) return null;
|
|
155
|
+
if (typeof cell?.value !== "number" || !Number.isFinite(cell.value) || cell.value < 0) throw new Error(`${field} inv\xE1lido en fila ${row}. Se requiere un n\xFAmero no negativo exportado.`);
|
|
156
|
+
return cell.value;
|
|
157
|
+
}
|
|
158
|
+
function parseCatalogRows({ rows, sheet, sourceHash, columns, headerRow }) {
|
|
159
|
+
const nodes = [], concepts = [], ignoredRows = [], warnings = [];
|
|
160
|
+
const byCode = /* @__PURE__ */ new Map(), qualified = /* @__PURE__ */ new Set();
|
|
161
|
+
if (rows.some((row, index) => !Number.isInteger(row.row) || row.row < 1 || index > 0 && row.row <= rows[index - 1].row)) throw new Error("Filas XLSX duplicadas o fuera de orden.");
|
|
162
|
+
let current = null;
|
|
163
|
+
for (const row of rows.filter((item) => item.row > headerRow)) {
|
|
164
|
+
const get = (field) => row.cells[columns[field]];
|
|
165
|
+
const codeValue = get("code")?.value, descriptionValue = get("description")?.value;
|
|
166
|
+
const code = String(codeValue ?? "").trim(), description = String(descriptionValue ?? "").trim();
|
|
167
|
+
if (Object.values(row.cells).every((cell) => (cell.value == null || cell.value === "") && cell.formula == null && !cell.error)) continue;
|
|
168
|
+
for (const field of Object.keys(columns)) if (get(field)?.formula !== null && get(field)?.formula !== void 0 || get(field)?.error)
|
|
169
|
+
throw new Error(`F\xF3rmula o error en ${sheet}!${columns[field]}${row.row}: exportar valores verificados.`);
|
|
170
|
+
if (columnsOf(row)) {
|
|
171
|
+
ignoredRows.push({ row: row.row, reason: "repeated-header" });
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if ((/^TOTAL\b/i.test(code) || code.startsWith("(*") || groupCode.test(code) && /^TOTAL\b/i.test(description)) && !get("unit")?.value && get("quantity")?.value == null) {
|
|
175
|
+
ignoredRows.push({ row: row.row, reason: "total-or-footer" });
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (!code || !description || typeof descriptionValue !== "string") throw new Error(`Fila ${row.row} incompleta: falta c\xF3digo o descripci\xF3n.`);
|
|
179
|
+
if (groupCode.test(code) && !get("unit")?.value && get("quantity")?.value == null) {
|
|
180
|
+
if (byCode.has(code)) throw new Error(`Nodo WBS duplicado: ${code}`);
|
|
181
|
+
const parentCode = code.includes(".") ? code.slice(0, code.lastIndexOf(".")) : null;
|
|
182
|
+
if (parentCode && !byCode.has(parentCode)) throw new Error(`Falta padre WBS ${parentCode} antes de ${code}`);
|
|
183
|
+
current = { id: `${sourceHash}:${sheet}:wbs:${row.row}`, code, label: description, parentId: parentCode ? byCode.get(parentCode).id : null, parentCode, row: row.row };
|
|
184
|
+
nodes.push(current);
|
|
185
|
+
byCode.set(code, current);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (!current) throw new Error(`Concepto sin cap\xEDtulo expl\xEDcito en fila ${row.row}`);
|
|
189
|
+
if (typeof codeValue !== "string" || /\s/.test(code) || code !== codeValue || groupCode.test(code)) throw new Error(`C\xF3digo de concepto inv\xE1lido en fila ${row.row}`);
|
|
190
|
+
if (typeof get("unit")?.value !== "string" || !get("unit").value.trim()) throw new Error(`Unidad faltante en fila ${row.row}`);
|
|
191
|
+
const qualifiedCode = `${current.code}-${code}`;
|
|
192
|
+
if (qualified.has(qualifiedCode)) throw new Error(`Identidad de concepto ambigua/duplicada: ${qualifiedCode}`);
|
|
193
|
+
qualified.add(qualifiedCode);
|
|
194
|
+
concepts.push({
|
|
195
|
+
id: `${sourceHash}:${sheet}:${row.row}`,
|
|
196
|
+
code,
|
|
197
|
+
qualifiedCode,
|
|
198
|
+
description,
|
|
199
|
+
unit: get("unit").value.trim(),
|
|
200
|
+
quantity: numberField(get("quantity"), "Cantidad", row.row),
|
|
201
|
+
unitPrice: numberField(get("unitPrice"), "Precio unitario", row.row, true),
|
|
202
|
+
amount: numberField(get("amount"), "Importe", row.row, true),
|
|
203
|
+
chapterCode: current.code,
|
|
204
|
+
chapter: current.label,
|
|
205
|
+
chapterRow: current.row,
|
|
206
|
+
parentId: current.id,
|
|
207
|
+
sheet,
|
|
208
|
+
row: row.row,
|
|
209
|
+
provenance: { sourceHash, sheet, row: row.row, chapterRow: current.row, columns }
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
if (!nodes.length || !concepts.length) throw new Error("El cat\xE1logo requiere cap\xEDtulos WBS y conceptos expl\xEDcitos.");
|
|
213
|
+
if (ignoredRows.length) warnings.push(`${ignoredRows.length} filas de encabezados repetidos, totales o pies se conservaron en auditor\xEDa; no son conceptos.`);
|
|
214
|
+
return { sheet, sourceHash, headerRow, columns, conceptCount: concepts.length, roots: nodes.filter((node) => node.parentId === null), nodes, concepts, ignoredRows, warnings };
|
|
215
|
+
}
|
|
216
|
+
function readHouseCatalog(bytes) {
|
|
217
|
+
if (!(bytes instanceof Uint8Array) || bytes.length > 10 * 1024 * 1024) throw new Error("El cat\xE1logo debe ser un XLSX de hasta 10 MB.");
|
|
218
|
+
const candidates = sheetsOf(bytes).flatMap((sheet) => sheet.rows.flatMap((row) => {
|
|
219
|
+
const columns = columnsOf(row);
|
|
220
|
+
return columns ? [{ ...sheet, columns, headerRow: row.row }] : [];
|
|
221
|
+
}));
|
|
222
|
+
const sheets = [...new Set(candidates.map((candidate2) => candidate2.sheet))];
|
|
223
|
+
if (sheets.length !== 1) throw new Error(sheets.length ? "Varias hojas tienen cat\xE1logo; entregar una sola hoja de conceptos inequ\xEDvoca." : "No se encontraron encabezados C\xF3digo, Concepto/Descripci\xF3n, Unidad y Cantidad.");
|
|
224
|
+
const candidate = candidates[0];
|
|
225
|
+
return parseCatalogRows({ ...candidate, sourceHash: hash(bytes) });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// src/processing/houses/pipeline.mjs
|
|
229
|
+
import { PDFDocument as PDFDocument3 } from "pdf-lib";
|
|
230
|
+
|
|
231
|
+
// src/processing/model/ifc-metadata.mjs
|
|
232
|
+
import { createRequire } from "node:module";
|
|
233
|
+
|
|
234
|
+
// src/processing/model/measurements.mjs
|
|
235
|
+
var dimensions = {
|
|
236
|
+
length: { unitType: "LENGTHUNIT", name: "METRE", unit: "m", exponent: 1 },
|
|
237
|
+
area: { unitType: "AREAUNIT", name: "SQUARE_METRE", unit: "m\xB2", exponent: 2 },
|
|
238
|
+
volume: {
|
|
239
|
+
unitType: "VOLUMEUNIT",
|
|
240
|
+
name: "CUBIC_METRE",
|
|
241
|
+
unit: "m\xB3",
|
|
242
|
+
exponent: 3
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
var prefixes = {
|
|
246
|
+
EXA: 1e18,
|
|
247
|
+
PETA: 1e15,
|
|
248
|
+
TERA: 1e12,
|
|
249
|
+
GIGA: 1e9,
|
|
250
|
+
MEGA: 1e6,
|
|
251
|
+
KILO: 1e3,
|
|
252
|
+
HECTO: 100,
|
|
253
|
+
DECA: 10,
|
|
254
|
+
DECI: 0.1,
|
|
255
|
+
CENTI: 0.01,
|
|
256
|
+
MILLI: 1e-3,
|
|
257
|
+
MICRO: 1e-6,
|
|
258
|
+
NANO: 1e-9,
|
|
259
|
+
PICO: 1e-12,
|
|
260
|
+
FEMTO: 1e-15,
|
|
261
|
+
ATTO: 1e-18
|
|
262
|
+
};
|
|
263
|
+
var measureTypes = {
|
|
264
|
+
IFCLENGTHMEASURE: "length",
|
|
265
|
+
IFCPOSITIVELENGTHMEASURE: "length",
|
|
266
|
+
IFCNONNEGATIVELENGTHMEASURE: "length",
|
|
267
|
+
IFCAREAMEASURE: "area",
|
|
268
|
+
IFCVOLUMEMEASURE: "volume"
|
|
269
|
+
};
|
|
270
|
+
var quantityFields = {
|
|
271
|
+
LengthValue: "length",
|
|
272
|
+
AreaValue: "area",
|
|
273
|
+
VolumeValue: "volume"
|
|
274
|
+
};
|
|
275
|
+
var labels = {
|
|
276
|
+
Length: "Longitud",
|
|
277
|
+
Height: "Altura",
|
|
278
|
+
Width: "Ancho",
|
|
279
|
+
Thickness: "Espesor",
|
|
280
|
+
Area: "\xC1rea exportada",
|
|
281
|
+
Volume: "Volumen exportado",
|
|
282
|
+
Perimeter: "Per\xEDmetro",
|
|
283
|
+
GrossArea: "\xC1rea bruta exportada",
|
|
284
|
+
NetArea: "\xC1rea neta exportada",
|
|
285
|
+
GrossSideArea: "\xC1rea lateral bruta exportada",
|
|
286
|
+
NetSideArea: "\xC1rea lateral neta exportada",
|
|
287
|
+
GrossFootprintArea: "\xC1rea de huella bruta exportada",
|
|
288
|
+
NetFootprintArea: "\xC1rea de huella neta exportada",
|
|
289
|
+
GrossVolume: "Volumen bruto exportado",
|
|
290
|
+
NetVolume: "Volumen neto exportado",
|
|
291
|
+
"Unconnected Height": "Altura no conectada",
|
|
292
|
+
"Rough Height": "Altura de hueco",
|
|
293
|
+
"Rough Width": "Ancho de hueco",
|
|
294
|
+
"Default Thickness": "Espesor predeterminado",
|
|
295
|
+
OverallHeight: "Altura total",
|
|
296
|
+
OverallWidth: "Ancho total",
|
|
297
|
+
NominalHeight: "Altura nominal",
|
|
298
|
+
NominalLength: "Longitud nominal",
|
|
299
|
+
NominalWidth: "Ancho nominal",
|
|
300
|
+
CrossSectionArea: "\xC1rea de secci\xF3n transversal",
|
|
301
|
+
OuterSurfaceArea: "\xC1rea exterior exportada",
|
|
302
|
+
"System Length": "Longitud del sistema"
|
|
303
|
+
};
|
|
304
|
+
var value = (wrapped) => wrapped?.value;
|
|
305
|
+
function measurementUnit(unit, dimension, seen = /* @__PURE__ */ new Set()) {
|
|
306
|
+
const definition = dimensions[dimension];
|
|
307
|
+
if (!unit || !definition || seen.size > 32 || seen.has(unit) || value(unit.UnitType) !== definition.unitType)
|
|
308
|
+
return null;
|
|
309
|
+
const visited = new Set(seen).add(unit);
|
|
310
|
+
if (unit.ConversionFactor) {
|
|
311
|
+
if (unit.ConversionOffset != null && value(unit.ConversionOffset) !== 0)
|
|
312
|
+
return null;
|
|
313
|
+
const factor = value(unit.ConversionFactor.ValueComponent);
|
|
314
|
+
const base = measurementUnit(
|
|
315
|
+
unit.ConversionFactor.UnitComponent,
|
|
316
|
+
dimension,
|
|
317
|
+
visited
|
|
318
|
+
);
|
|
319
|
+
if (!base || typeof factor !== "number" || !Number.isFinite(factor) || factor <= 0 || !Number.isFinite(factor * base.scale) || factor * base.scale <= 0)
|
|
320
|
+
return null;
|
|
321
|
+
return { unit: definition.unit, scale: factor * base.scale };
|
|
322
|
+
}
|
|
323
|
+
if (value(unit.Name) !== definition.name || !Object.hasOwn(unit, "Prefix"))
|
|
324
|
+
return null;
|
|
325
|
+
const prefix = unit.Prefix == null ? 1 : prefixes[value(unit.Prefix)];
|
|
326
|
+
const scale = prefix ** definition.exponent;
|
|
327
|
+
return Number.isFinite(scale) && scale > 0 ? { unit: definition.unit, scale } : null;
|
|
328
|
+
}
|
|
329
|
+
function extractMeasurements({
|
|
330
|
+
instanceSets = [],
|
|
331
|
+
typeSets = [],
|
|
332
|
+
projectUnits = [],
|
|
333
|
+
element = {},
|
|
334
|
+
category = "",
|
|
335
|
+
widthParameter = "Width"
|
|
336
|
+
}) {
|
|
337
|
+
const measurements = [], measurementIssues = [], widths = [];
|
|
338
|
+
const unitFor = (explicit, dimension) => {
|
|
339
|
+
if (explicit != null)
|
|
340
|
+
return {
|
|
341
|
+
node: explicit,
|
|
342
|
+
resolved: measurementUnit(explicit, dimension),
|
|
343
|
+
source: "expl\xEDcita"
|
|
344
|
+
};
|
|
345
|
+
const candidates = projectUnits.filter(
|
|
346
|
+
(unit) => value(unit.UnitType) === dimensions[dimension].unitType
|
|
347
|
+
);
|
|
348
|
+
return {
|
|
349
|
+
node: candidates[0],
|
|
350
|
+
resolved: candidates.length === 1 ? measurementUnit(candidates[0], dimension) : null,
|
|
351
|
+
source: "de proyecto"
|
|
352
|
+
};
|
|
353
|
+
};
|
|
354
|
+
const add3 = ({
|
|
355
|
+
raw,
|
|
356
|
+
dimension,
|
|
357
|
+
unit,
|
|
358
|
+
name,
|
|
359
|
+
setName,
|
|
360
|
+
setId,
|
|
361
|
+
propertyId,
|
|
362
|
+
scope
|
|
363
|
+
}) => {
|
|
364
|
+
if (!dimension) return;
|
|
365
|
+
const source = `${scope} \xB7 ${setName}.${name} \xB7 IFC #${propertyId ?? setId ?? element.expressID ?? "sin identificador"}`;
|
|
366
|
+
const selected = unitFor(unit, dimension);
|
|
367
|
+
if (typeof raw !== "number" || !Number.isFinite(raw)) {
|
|
368
|
+
measurementIssues.push(
|
|
369
|
+
`${source}: valor no num\xE9rico o no finito; pendiente.`
|
|
370
|
+
);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
if (!selected.resolved) {
|
|
374
|
+
measurementIssues.push(
|
|
375
|
+
`${source}: unidad ${selected.source} ausente, ambigua o no soportada; pendiente.`
|
|
376
|
+
);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
const normalized2 = raw * selected.resolved.scale;
|
|
380
|
+
if (!Number.isFinite(normalized2)) {
|
|
381
|
+
measurementIssues.push(`${source}: conversi\xF3n fuera de rango; pendiente.`);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
const wallWidth = /^IfcWall(?:StandardCase|ElementedCase)?$/i.test(category) && name === widthParameter && dimension === "length";
|
|
385
|
+
const key = `${scope}:${setId ?? setName}:${propertyId ?? name}`;
|
|
386
|
+
measurements.push({
|
|
387
|
+
key,
|
|
388
|
+
label: wallWidth ? "Espesor" : labels[name] ?? name,
|
|
389
|
+
value: normalized2,
|
|
390
|
+
unit: selected.resolved.unit,
|
|
391
|
+
source: `${source} \xB7 unidad ${selected.source} IFC #${selected.node?.expressID ?? "sin identificador"}`
|
|
392
|
+
});
|
|
393
|
+
if (wallWidth) widths.push(normalized2);
|
|
394
|
+
};
|
|
395
|
+
const visit = (sets, scope) => {
|
|
396
|
+
for (const set of sets) {
|
|
397
|
+
const setName = value(set.Name) ?? "Conjunto sin nombre";
|
|
398
|
+
for (const property of set.HasProperties ?? []) {
|
|
399
|
+
add3({
|
|
400
|
+
raw: value(property.NominalValue),
|
|
401
|
+
dimension: measureTypes[property.NominalValue?.name],
|
|
402
|
+
unit: property.Unit,
|
|
403
|
+
name: value(property.Name) ?? "Propiedad sin nombre",
|
|
404
|
+
setName,
|
|
405
|
+
setId: set.expressID,
|
|
406
|
+
propertyId: property.expressID,
|
|
407
|
+
scope
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
for (const quantity of set.Quantities ?? []) {
|
|
411
|
+
const supported = Object.entries(quantityFields).filter(
|
|
412
|
+
([field2]) => quantity[field2] != null
|
|
413
|
+
);
|
|
414
|
+
if (supported.length !== 1) continue;
|
|
415
|
+
const [field, dimension] = supported[0];
|
|
416
|
+
add3({
|
|
417
|
+
raw: value(quantity[field]),
|
|
418
|
+
dimension,
|
|
419
|
+
unit: quantity.Unit,
|
|
420
|
+
name: value(quantity.Name) ?? field,
|
|
421
|
+
setName,
|
|
422
|
+
setId: set.expressID,
|
|
423
|
+
propertyId: quantity.expressID,
|
|
424
|
+
scope
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
};
|
|
429
|
+
visit(instanceSets, "Instancia");
|
|
430
|
+
visit(typeSets, "Tipo");
|
|
431
|
+
for (const name of ["OverallHeight", "OverallWidth"]) {
|
|
432
|
+
if (element[name] != null)
|
|
433
|
+
add3({
|
|
434
|
+
raw: value(element[name]),
|
|
435
|
+
dimension: "length",
|
|
436
|
+
name,
|
|
437
|
+
setName: category,
|
|
438
|
+
setId: element.expressID,
|
|
439
|
+
propertyId: `${element.expressID}.${name}`,
|
|
440
|
+
scope: "Instancia"
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
const uniqueWidths = [...new Set(widths)];
|
|
444
|
+
if (uniqueWidths.length > 1)
|
|
445
|
+
measurementIssues.push(
|
|
446
|
+
"Espesor Width del muro ambiguo: se conservan los valores y sus fuentes, sin elegir uno."
|
|
447
|
+
);
|
|
448
|
+
return {
|
|
449
|
+
measurements,
|
|
450
|
+
measurementIssues,
|
|
451
|
+
thickness: uniqueWidths.length === 1 ? uniqueWidths[0] : null
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// src/processing/model/identity.mjs
|
|
456
|
+
function indexModelIdentity(elements) {
|
|
457
|
+
const byTag = /* @__PURE__ */ new Map(), byGlobalId = /* @__PURE__ */ new Map();
|
|
458
|
+
for (const element of elements) {
|
|
459
|
+
if (typeof element.id !== "string" || !/^[0-3][0-9A-Za-z_$]{21}$/.test(element.id)) throw new Error("Missing or invalid IFC GlobalId");
|
|
460
|
+
if (byGlobalId.has(element.id)) throw new Error(`Duplicate IFC GlobalId: ${element.id}`);
|
|
461
|
+
byGlobalId.set(element.id, element);
|
|
462
|
+
if (!element.elementId) continue;
|
|
463
|
+
if (byTag.has(element.elementId)) throw new Error(`Ambiguous explicit IFC Tag: ${element.elementId}`);
|
|
464
|
+
byTag.set(element.elementId, element);
|
|
465
|
+
}
|
|
466
|
+
return { byTag, byGlobalId };
|
|
467
|
+
}
|
|
468
|
+
function spatialLevel(id, parents, levels) {
|
|
469
|
+
const found = /* @__PURE__ */ new Map(), visited = /* @__PURE__ */ new Set(), pending2 = [id];
|
|
470
|
+
while (pending2.length) {
|
|
471
|
+
const current = pending2.pop();
|
|
472
|
+
if (visited.has(current)) continue;
|
|
473
|
+
visited.add(current);
|
|
474
|
+
if (levels.has(current)) found.set(current, levels.get(current));
|
|
475
|
+
pending2.push(...parents.get(current) ?? []);
|
|
476
|
+
}
|
|
477
|
+
return { level: found.size === 1 ? [...found.values()][0] : "", ambiguousLevel: found.size > 1 };
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// src/processing/model/display-names.mjs
|
|
481
|
+
var clean = (value2) => typeof value2 === "string" ? value2.replace(/_x000D_/gi, " ").replace(/\s+/g, " ").trim() : "";
|
|
482
|
+
var compareText2 = (a, b) => a < b ? -1 : a > b ? 1 : 0;
|
|
483
|
+
var prefixes2 = /^(?:(?:SUMINISTRO Y (?:COLOCACI[OÓ]N|MONTAJE|APLICACI[OÓ]N)|SUMINISTRO E INSTALACI[OÓ]N|CONSTRUCCI[OÓ]N|FABRICACI[OÓ]N|COLOCACI[OÓ]N|INSTALACI[OÓ]N) DE\s+)/i;
|
|
484
|
+
function shortName(value2) {
|
|
485
|
+
const maxLength = 48;
|
|
486
|
+
const text = clean(value2);
|
|
487
|
+
if (text.length <= maxLength) return text;
|
|
488
|
+
const head = text.slice(0, maxLength - 1);
|
|
489
|
+
const boundary = head.lastIndexOf(" ");
|
|
490
|
+
return `${(boundary > maxLength / 2 ? head.slice(0, boundary) : head).trimEnd()}\u2026`;
|
|
491
|
+
}
|
|
492
|
+
function conceptName(description) {
|
|
493
|
+
const original = clean(description);
|
|
494
|
+
const body = original.replace(prefixes2, "");
|
|
495
|
+
const main2 = body.split(/\s+(?:INCLUYE|INCLUYENDO|INC\.?)(?:\s|:)|;|,(?=\s)|\.(?=\s)|\s+(?:CONSISTENTE EN|POR MEDIOS|HECH[OA] (?:CON|A BASE)|ASENTAD[OA] CON|REFORZAD[OA] CON|CON ACABADO|EN APARIENCIA)\b|\s+(?:(?:DE|DEL|CON|EN)\s+)?\d+(?:[.,]\d+)?\s*(?:[x×]|\b(?:CM|CMS|MM|MTS|M|METROS?)\b)/i)[0].trim();
|
|
496
|
+
const selected = main2 || original;
|
|
497
|
+
let result = selected.toLocaleLowerCase("es-MX").replace(/\b(?:pvc|cpvc|wpc|mdf|led|hvac|pb|pa|az)\b/gi, (token) => token.toUpperCase());
|
|
498
|
+
for (const token of selected.match(/\b[A-Z]{1,6}-?\d+(?:-\d+)*\b/g) ?? []) {
|
|
499
|
+
result = result.replaceAll(token.toLowerCase(), token);
|
|
500
|
+
}
|
|
501
|
+
return shortName(result.charAt(0).toLocaleUpperCase("es-MX") + result.slice(1));
|
|
502
|
+
}
|
|
503
|
+
function propertyCandidates(sets, names2, scope) {
|
|
504
|
+
return sets.flatMap((set) => (set.HasProperties ?? []).flatMap((property) => {
|
|
505
|
+
const name = property.Name?.value;
|
|
506
|
+
const value2 = clean(property.NominalValue?.value);
|
|
507
|
+
return names2.includes(name) && value2 ? [{
|
|
508
|
+
value: value2,
|
|
509
|
+
source: `${scope} \xB7 ${set.Name?.value ?? "Conjunto sin nombre"}.${name} \xB7 IFC #${property.expressID ?? set.expressID ?? "sin identificador"}`
|
|
510
|
+
}] : [];
|
|
511
|
+
}));
|
|
512
|
+
}
|
|
513
|
+
function uniqueDescriptor(candidates) {
|
|
514
|
+
const values = [...new Set(candidates.map((candidate) => candidate.value))].toSorted(compareText2);
|
|
515
|
+
return {
|
|
516
|
+
value: values.length === 1 ? values[0] : "",
|
|
517
|
+
candidates: candidates.toSorted((a, b) => compareText2(a.value, b.value) || compareText2(a.source, b.source)),
|
|
518
|
+
ambiguous: values.length > 1
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
function modelDescriptors(instanceSets = [], types = []) {
|
|
522
|
+
const typeSets = types.flatMap((type2) => type2.HasPropertySets ?? []);
|
|
523
|
+
const family = uniqueDescriptor([
|
|
524
|
+
...propertyCandidates(instanceSets, ["Family", "Family Name"], "Instancia"),
|
|
525
|
+
...propertyCandidates(typeSets, ["Family", "Family Name"], "Tipo")
|
|
526
|
+
]);
|
|
527
|
+
const namedTypes = [
|
|
528
|
+
...propertyCandidates(instanceSets, ["Type", "Type Name"], "Instancia"),
|
|
529
|
+
...propertyCandidates(typeSets, ["Type", "Type Name"], "Tipo")
|
|
530
|
+
];
|
|
531
|
+
const type = uniqueDescriptor(namedTypes.length ? namedTypes : types.flatMap((item) => {
|
|
532
|
+
const value2 = clean(item.Name?.value);
|
|
533
|
+
return value2 ? [{ value: value2, source: `IfcTypeObject.Name \xB7 IFC #${item.expressID ?? "sin identificador"}` }] : [];
|
|
534
|
+
}));
|
|
535
|
+
return { family: family.value, typeName: type.value, descriptorEvidence: { family, type } };
|
|
536
|
+
}
|
|
537
|
+
function fallbackName(element) {
|
|
538
|
+
const category = clean(element.categoryLabel) || "Elemento";
|
|
539
|
+
const descriptor = clean(element.typeName) || clean(element.family);
|
|
540
|
+
if (!descriptor) return shortName(category);
|
|
541
|
+
const categoryStart = descriptor.toLocaleLowerCase("es-MX").startsWith(`${category.toLocaleLowerCase("es-MX")} `);
|
|
542
|
+
return shortName(categoryStart || descriptor.toLocaleLowerCase("es-MX") === category.toLocaleLowerCase("es-MX") ? descriptor : `${category} \xB7 ${descriptor}`);
|
|
543
|
+
}
|
|
544
|
+
function withDisplayNames(elements, concepts = []) {
|
|
545
|
+
const byConcept = new Map(concepts.map((concept) => [concept.id, concept]));
|
|
546
|
+
return elements.map((element) => {
|
|
547
|
+
const namesByConcept = {}, byConceptEvidence = {};
|
|
548
|
+
const confirmed = (element.relations ?? []).filter((relation) => relation.status === "confirmed" && !relation.ambiguous).toSorted((a, b) => compareText2(a.conceptId, b.conceptId));
|
|
549
|
+
for (const relation of confirmed) {
|
|
550
|
+
const concept = byConcept.get(relation.conceptId);
|
|
551
|
+
if (!concept || !clean(concept.description)) continue;
|
|
552
|
+
namesByConcept[concept.id] = conceptName(concept.description);
|
|
553
|
+
byConceptEvidence[concept.id] = {
|
|
554
|
+
rule: "catalog-literal-nominal-phrase-before-dimensions-or-execution-clause-v2; sentence-case-preserve-codes; word-boundary-ellipsis-48",
|
|
555
|
+
catalogSourceHash: concept.provenance?.sourceHash ?? null,
|
|
556
|
+
sheet: concept.sheet ?? null,
|
|
557
|
+
row: concept.row ?? null
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
return {
|
|
561
|
+
...element,
|
|
562
|
+
displayName: fallbackName(element),
|
|
563
|
+
namesByConcept,
|
|
564
|
+
nameEvidence: {
|
|
565
|
+
fallback: { rule: "ifc-category-with-explicit-type-or-family; word-boundary-ellipsis-48", descriptors: element.descriptorEvidence ?? null },
|
|
566
|
+
byConcept: byConceptEvidence
|
|
567
|
+
}
|
|
568
|
+
};
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// src/processing/model/element-dimensions.mjs
|
|
573
|
+
var fields = ["length", "width", "height"];
|
|
574
|
+
var priorities = ["instance", "type", "bounding-box"];
|
|
575
|
+
var labels2 = { length: "Largo", width: "Ancho", height: "Alto" };
|
|
576
|
+
var names = { Length: "length", Width: "width", Height: "height", OverallWidth: "width", OverallHeight: "height" };
|
|
577
|
+
var lengthTypes = /* @__PURE__ */ new Set(["IFCLENGTHMEASURE", "IFCPOSITIVELENGTHMEASURE", "IFCNONNEGATIVELENGTHMEASURE"]);
|
|
578
|
+
var unwrap = (value2) => value2?.value;
|
|
579
|
+
var equivalent = (a, b) => Math.abs(a - b) <= 1e-9 * Math.max(1, Math.abs(a), Math.abs(b));
|
|
580
|
+
var bySource = (a, b) => JSON.stringify(a.source).localeCompare(JSON.stringify(b.source), "en");
|
|
581
|
+
function targetField(name, category) {
|
|
582
|
+
if (Object.hasOwn(names, name)) return names[name];
|
|
583
|
+
if (name !== "Thickness") return null;
|
|
584
|
+
if (/^IFCWALL(?:STANDARDCASE|ELEMENTEDCASE)?$/i.test(category)) return "width";
|
|
585
|
+
return null;
|
|
586
|
+
}
|
|
587
|
+
function propertyLabel(name, field, category) {
|
|
588
|
+
return name === "Thickness" || name === "Width" && /^IFCWALL(?:STANDARDCASE|ELEMENTEDCASE)?$/i.test(category) ? "Espesor" : labels2[field];
|
|
589
|
+
}
|
|
590
|
+
function propertyRule(name, category) {
|
|
591
|
+
if (name === "Thickness" || name === "Width" && /^IFCWALL(?:STANDARDCASE|ELEMENTEDCASE)?$/i.test(category))
|
|
592
|
+
return "Espesor declarado del muro, presentado en width; no se obtiene del dibujo ni de otras dimensiones.";
|
|
593
|
+
return `Atributo o propiedad dimensional ${name} exportado literalmente; no se infiere su orientaci\xF3n a partir del nombre del elemento.`;
|
|
594
|
+
}
|
|
595
|
+
function resolveUnit(explicit, projectUnits) {
|
|
596
|
+
const candidates = explicit != null ? [explicit] : projectUnits.filter((unit) => unwrap(unit.UnitType) === "LENGTHUNIT");
|
|
597
|
+
const node = candidates.length === 1 ? candidates[0] : null;
|
|
598
|
+
return { node, resolved: measurementUnit(node, "length"), assignment: explicit != null ? "explicit" : "project" };
|
|
599
|
+
}
|
|
600
|
+
function extractElementDimensions({ element, category = "", instanceSets = [], types = [], projectUnits = [], boundingBoxes = [] }) {
|
|
601
|
+
const candidates = [], issues = [];
|
|
602
|
+
const identity2 = { globalId: unwrap(element.GlobalId), elementExpressId: element.expressID };
|
|
603
|
+
const add3 = ({ field, rawValue, kind, label: label2, unit, source, supported = true }) => {
|
|
604
|
+
const units3 = resolveUnit(unit, projectUnits);
|
|
605
|
+
const provenance = { ...identity2, ...source, unitAssignment: units3.assignment, unitExpressId: units3.node?.expressID ?? null, unitScale: units3.resolved?.scale ?? null };
|
|
606
|
+
let reason = null;
|
|
607
|
+
if (!supported) reason = "El valor no est\xE1 declarado como una medida de longitud IFC.";
|
|
608
|
+
else if (typeof rawValue !== "number" || !Number.isFinite(rawValue) || rawValue <= 0) reason = "La dimensi\xF3n debe ser num\xE9rica, finita y mayor que cero.";
|
|
609
|
+
else if (!units3.resolved) reason = "La unidad IFC est\xE1 ausente, es ambigua o no est\xE1 soportada.";
|
|
610
|
+
const normalized2 = reason ? null : rawValue * units3.resolved.scale;
|
|
611
|
+
if (normalized2 !== null && (!Number.isFinite(normalized2) || normalized2 <= 0)) reason = "La conversi\xF3n de unidad est\xE1 fuera de rango.";
|
|
612
|
+
const candidate = { field, value: reason ? null : normalized2, rawValue: Number.isFinite(rawValue) ? rawValue : null, unit: "m", kind, label: label2, source: provenance, ...reason ? { reason } : {} };
|
|
613
|
+
candidates.push(candidate);
|
|
614
|
+
if (reason) issues.push({ field, kind, reason, source: provenance });
|
|
615
|
+
};
|
|
616
|
+
const visit = (sets, kind, typeId) => {
|
|
617
|
+
for (const set of sets) {
|
|
618
|
+
const common = { propertySetExpressId: set.expressID, propertySetName: unwrap(set.Name) ?? "", ...typeId == null ? {} : { ifcTypeExpressId: typeId } };
|
|
619
|
+
for (const property of [...set.HasProperties ?? [], ...set.Quantities ?? []]) {
|
|
620
|
+
const name = unwrap(property.Name), field = targetField(name, category);
|
|
621
|
+
if (!field) continue;
|
|
622
|
+
const quantity = property.LengthValue != null;
|
|
623
|
+
add3({
|
|
624
|
+
field,
|
|
625
|
+
rawValue: unwrap(quantity ? property.LengthValue : property.NominalValue),
|
|
626
|
+
kind,
|
|
627
|
+
label: propertyLabel(name, field, category),
|
|
628
|
+
unit: property.Unit,
|
|
629
|
+
supported: quantity || lengthTypes.has(property.NominalValue?.name),
|
|
630
|
+
source: { ...common, propertyExpressId: property.expressID, propertyName: name, rule: propertyRule(name, category) }
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
};
|
|
635
|
+
visit(instanceSets, "instance");
|
|
636
|
+
for (const name of Object.keys(names)) {
|
|
637
|
+
if (element[name] == null) continue;
|
|
638
|
+
const field = names[name];
|
|
639
|
+
add3({
|
|
640
|
+
field,
|
|
641
|
+
rawValue: unwrap(element[name]),
|
|
642
|
+
kind: "instance",
|
|
643
|
+
label: propertyLabel(name, field, category),
|
|
644
|
+
source: { propertyName: name, rule: propertyRule(name, category) }
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
for (const type of types) visit(type.HasPropertySets ?? [], "type", type.expressID);
|
|
648
|
+
for (const { box, representationExpressId, productRepresentationExpressId } of boundingBoxes) {
|
|
649
|
+
const x = unwrap(box.XDim), y = unwrap(box.YDim);
|
|
650
|
+
const horizontalValid = [x, y].every((value2) => typeof value2 === "number" && Number.isFinite(value2) && value2 > 0);
|
|
651
|
+
const mapping = { length: horizontalValid && y > x ? "YDim" : "XDim", width: horizontalValid && y > x ? "XDim" : "YDim", height: "ZDim" };
|
|
652
|
+
for (const field of fields) {
|
|
653
|
+
const axis = mapping[field];
|
|
654
|
+
add3({
|
|
655
|
+
field,
|
|
656
|
+
rawValue: field !== "height" && !horizontalValid ? null : unwrap(box[axis]),
|
|
657
|
+
kind: "bounding-box",
|
|
658
|
+
label: `${labels2[field]} de envolvente`,
|
|
659
|
+
source: {
|
|
660
|
+
boundingBoxExpressId: box.expressID,
|
|
661
|
+
representationExpressId,
|
|
662
|
+
productRepresentationExpressId,
|
|
663
|
+
axis,
|
|
664
|
+
rule: field === "height" ? "ZDim de la caja IFC, en su eje local Z; no implica altura vertical global ni medida exacta de la pieza." : `${field === "length" ? "M\xE1ximo" : "M\xEDnimo"} de XDim e YDim de la caja IFC en ejes locales; dimensi\xF3n de envolvente, no longitud f\xEDsica exacta ni distancia sobre el plano.`
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
const sorted = candidates.toSorted((a, b) => fields.indexOf(a.field) - fields.indexOf(b.field) || priorities.indexOf(a.kind) - priorities.indexOf(b.kind) || bySource(a, b));
|
|
670
|
+
const discrepancies = [];
|
|
671
|
+
const dimensions2 = Object.fromEntries(fields.map((field) => {
|
|
672
|
+
const options = sorted.filter((candidate) => candidate.field === field);
|
|
673
|
+
const none = { value: null, unit: "m", kind: null, label: labels2[field], source: null, discrepancy: false };
|
|
674
|
+
if (!options.length) return [field, { ...none, reason: "El IFC no contiene una dimensi\xF3n compatible ni una caja envolvente directamente asociada." }];
|
|
675
|
+
const priority = options[0].kind;
|
|
676
|
+
const preferred = options.filter((candidate) => candidate.kind === priority);
|
|
677
|
+
const valid = preferred.filter((candidate) => candidate.value !== null);
|
|
678
|
+
const conflict = valid.some((candidate) => !equivalent(candidate.value, valid[0].value));
|
|
679
|
+
if (preferred.length !== valid.length || conflict) {
|
|
680
|
+
const reason2 = conflict ? "Existen dimensiones distintas con la misma prioridad; no se seleccion\xF3 una ni se sustituy\xF3 por una fuente secundaria." : "Una dimensi\xF3n de la fuente prioritaria no es v\xE1lida; requiere revisi\xF3n antes de usar una alternativa.";
|
|
681
|
+
discrepancies.push({ field, kind: priority, reason: reason2, candidates: preferred });
|
|
682
|
+
return [field, { ...none, discrepancy: true, reason: reason2 }];
|
|
683
|
+
}
|
|
684
|
+
const selected = valid[0];
|
|
685
|
+
const alternatives = options.filter((candidate) => candidate.value === null || !equivalent(candidate.value, selected.value));
|
|
686
|
+
const reason = alternatives.length ? `Existen valores distintos o no v\xE1lidos entre fuentes; se prioriza ${priority === "instance" ? "la instancia" : priority === "type" ? "el tipo" : "la caja envolvente"}. Una envolvente puede diferir de la dimensi\xF3n declarada.` : void 0;
|
|
687
|
+
if (reason) discrepancies.push({ field, kind: priority, reason, selected, alternatives });
|
|
688
|
+
return [field, { value: selected.value, unit: "m", kind: selected.kind, label: selected.label, source: selected.source, discrepancy: Boolean(reason), ...reason ? { reason } : {} }];
|
|
689
|
+
}));
|
|
690
|
+
return { dimensions: dimensions2, dimensionAudit: {
|
|
691
|
+
candidates: sorted,
|
|
692
|
+
discrepancies,
|
|
693
|
+
issues,
|
|
694
|
+
rules: "Instancia > tipo > caja IFC directamente asociada; igualdad num\xE9rica con tolerancia relativa 1e-9 y absoluta 1e-9 m. No se usan dimensiones nominales, nombres de familia, PDF ni presupuesto. No se transforman cajas de representaciones mapeadas."
|
|
695
|
+
} };
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// src/processing/model/ifc-metadata.mjs
|
|
699
|
+
var require2 = createRequire(import.meta.url);
|
|
700
|
+
var W = require2("web-ifc");
|
|
701
|
+
var categoryLabels = {
|
|
702
|
+
IFCWALL: "Muro",
|
|
703
|
+
IFCWALLSTANDARDCASE: "Muro",
|
|
704
|
+
IFCWALLELEMENTEDCASE: "Muro",
|
|
705
|
+
IFCDOOR: "Puerta",
|
|
706
|
+
IFCWINDOW: "Ventana",
|
|
707
|
+
IFCSLAB: "Losa",
|
|
708
|
+
IFCBEAM: "Viga",
|
|
709
|
+
IFCCOLUMN: "Columna",
|
|
710
|
+
IFCFOOTING: "Cimentaci\xF3n",
|
|
711
|
+
IFCSTAIR: "Escalera",
|
|
712
|
+
IFCSTAIRFLIGHT: "Tramo de escalera",
|
|
713
|
+
IFCMEMBER: "Miembro estructural",
|
|
714
|
+
IFCPLATE: "Placa",
|
|
715
|
+
IFCCOVERING: "Revestimiento",
|
|
716
|
+
IFCRAILING: "Barandal",
|
|
717
|
+
IFCBUILDINGELEMENTPROXY: "Elemento constructivo",
|
|
718
|
+
IFCFURNISHINGELEMENT: "Mobiliario",
|
|
719
|
+
IFCELEMENTASSEMBLY: "Conjunto",
|
|
720
|
+
IFCFLOWTERMINAL: "Terminal de instalaci\xF3n"
|
|
721
|
+
};
|
|
722
|
+
function directBoundingBoxes(api, model, element) {
|
|
723
|
+
if (!element.Representation) return [];
|
|
724
|
+
const product2 = api.GetLine(model, element.Representation.value);
|
|
725
|
+
return (product2.Representations ?? []).flatMap((reference) => {
|
|
726
|
+
const representation = api.GetLine(model, reference.value);
|
|
727
|
+
return (representation.Items ?? []).flatMap((item) => {
|
|
728
|
+
const box = api.GetLine(model, item.value);
|
|
729
|
+
return box.type === W.IFCBOUNDINGBOX ? [{ box, representationExpressId: representation.expressID, productRepresentationExpressId: product2.expressID }] : [];
|
|
730
|
+
});
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
async function readModel(bytes, parameters = {}) {
|
|
734
|
+
const api = new W.IfcAPI();
|
|
735
|
+
await api.Init();
|
|
736
|
+
const model = api.OpenModel(bytes);
|
|
737
|
+
if (model < 0) throw new Error("IFC could not be opened");
|
|
738
|
+
const lines = (type) => {
|
|
739
|
+
const ids = api.GetLineIDsWithType(model, type, true);
|
|
740
|
+
return Array.from({ length: ids.size() }, (_, i) => api.GetLine(model, ids.get(i)));
|
|
741
|
+
};
|
|
742
|
+
try {
|
|
743
|
+
const parents = /* @__PURE__ */ new Map(), levels = /* @__PURE__ */ new Map();
|
|
744
|
+
const addParents = (children, parent) => {
|
|
745
|
+
for (const child of children ?? []) parents.set(child.value, [...parents.get(child.value) ?? [], parent.value]);
|
|
746
|
+
};
|
|
747
|
+
for (const rel of lines(W.IFCRELCONTAINEDINSPATIALSTRUCTURE)) addParents(rel.RelatedElements, rel.RelatingStructure);
|
|
748
|
+
for (const rel of lines(W.IFCRELAGGREGATES)) addParents(rel.RelatedObjects, rel.RelatingObject);
|
|
749
|
+
for (const level of lines(W.IFCBUILDINGSTOREY)) levels.set(level.expressID, level.Name?.value ?? "");
|
|
750
|
+
const projects = lines(W.IFCPROJECT);
|
|
751
|
+
if (projects.length !== 1) throw new Error("IFC must contain one unambiguous project");
|
|
752
|
+
const projectUnits = api.GetLine(model, projects[0].expressID, true).UnitsInContext?.Units ?? [];
|
|
753
|
+
const elements = [];
|
|
754
|
+
for (const e of lines(W.IFCELEMENT).filter((item) => item.type !== W.IFCOPENINGELEMENT)) {
|
|
755
|
+
const sets = await api.properties.getPropertySets(model, e.expressID, true);
|
|
756
|
+
const types = await api.properties.getTypeProperties(model, e.expressID, true);
|
|
757
|
+
const props = sets.flatMap((set) => set.HasProperties ?? []);
|
|
758
|
+
const category = api.GetNameFromTypeCode(e.type);
|
|
759
|
+
const categoryLabel = categoryLabels[category.toUpperCase()] ?? "Elemento";
|
|
760
|
+
const elementId = typeof e.Tag?.value === "string" ? e.Tag.value : "";
|
|
761
|
+
const measurements = extractMeasurements({
|
|
762
|
+
instanceSets: sets,
|
|
763
|
+
typeSets: types.flatMap((type) => type.HasPropertySets ?? []),
|
|
764
|
+
projectUnits,
|
|
765
|
+
element: e,
|
|
766
|
+
category,
|
|
767
|
+
widthParameter: parameters.width ?? "Width"
|
|
768
|
+
});
|
|
769
|
+
const dimensions2 = extractElementDimensions({
|
|
770
|
+
element: e,
|
|
771
|
+
category,
|
|
772
|
+
instanceSets: sets,
|
|
773
|
+
types,
|
|
774
|
+
projectUnits,
|
|
775
|
+
boundingBoxes: directBoundingBoxes(api, model, e)
|
|
776
|
+
});
|
|
777
|
+
elements.push({
|
|
778
|
+
id: e.GlobalId?.value,
|
|
779
|
+
elementId,
|
|
780
|
+
name: `${categoryLabel}${elementId ? ` ${elementId}` : ""}`,
|
|
781
|
+
modelName: e.Name?.value ?? "",
|
|
782
|
+
category,
|
|
783
|
+
categoryLabel,
|
|
784
|
+
...modelDescriptors(sets, types),
|
|
785
|
+
...spatialLevel(e.expressID, parents, levels),
|
|
786
|
+
wbs: [...new Set(props.filter((p) => p.Name?.value === (parameters.wbs ?? "WBS")).map((p) => p.NominalValue?.value).filter((v) => typeof v === "string" && v.trim()))],
|
|
787
|
+
relations: [],
|
|
788
|
+
...measurements,
|
|
789
|
+
...dimensions2,
|
|
790
|
+
expressId: e.expressID
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
indexModelIdentity(elements);
|
|
794
|
+
return elements.toSorted((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
795
|
+
} finally {
|
|
796
|
+
api.CloseModel(model);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// src/processing/documents/pdf-geometry.mjs
|
|
801
|
+
import { PDFDocument as PDFDocument2, PDFArray, PDFName as PDFName2, decodePDFRawStream as decodePDFRawStream2 } from "pdf-lib";
|
|
802
|
+
|
|
803
|
+
// src/processing/documents/pdf-tokens.mjs
|
|
804
|
+
function* pdfTokens(content) {
|
|
805
|
+
let position = 0;
|
|
806
|
+
const whitespace = () => {
|
|
807
|
+
while (position < content.length) {
|
|
808
|
+
if (/[\s\0]/.test(content[position])) position++;
|
|
809
|
+
else if (content[position] === "%") {
|
|
810
|
+
while (position < content.length && !/[\r\n]/.test(content[position]))
|
|
811
|
+
position++;
|
|
812
|
+
} else break;
|
|
813
|
+
}
|
|
814
|
+
};
|
|
815
|
+
const value2 = (depth = 0) => {
|
|
816
|
+
if (depth > 64) throw new Error("PDF operand nesting exceeds limit");
|
|
817
|
+
whitespace();
|
|
818
|
+
const start = position;
|
|
819
|
+
const char = content[position++];
|
|
820
|
+
if (char === "(") {
|
|
821
|
+
let nested = 1;
|
|
822
|
+
while (position < content.length && nested) {
|
|
823
|
+
const next = content[position++];
|
|
824
|
+
if (next === "\\") position++;
|
|
825
|
+
else if (next === "(") nested++;
|
|
826
|
+
else if (next === ")") nested--;
|
|
827
|
+
}
|
|
828
|
+
if (nested) throw new Error("Unclosed PDF string");
|
|
829
|
+
return { type: "string", value: content.slice(start, position) };
|
|
830
|
+
}
|
|
831
|
+
if (char === "<") {
|
|
832
|
+
if (content[position] === "<") {
|
|
833
|
+
position++;
|
|
834
|
+
const entries = [];
|
|
835
|
+
while (true) {
|
|
836
|
+
whitespace();
|
|
837
|
+
if (content.slice(position, position + 2) === ">>") {
|
|
838
|
+
position += 2;
|
|
839
|
+
break;
|
|
840
|
+
}
|
|
841
|
+
if (position >= content.length)
|
|
842
|
+
throw new Error("Unclosed PDF dictionary");
|
|
843
|
+
entries.push(value2(depth + 1));
|
|
844
|
+
}
|
|
845
|
+
if (entries.length % 2 || entries.some(
|
|
846
|
+
(entry, index) => index % 2 === 0 && entry?.type !== "name"
|
|
847
|
+
))
|
|
848
|
+
throw new Error("Malformed PDF dictionary");
|
|
849
|
+
return { type: "dictionary", value: entries };
|
|
850
|
+
}
|
|
851
|
+
const end = content.indexOf(">", position);
|
|
852
|
+
if (end < 0 || !/^[\da-f\s]*$/i.test(content.slice(position, end)))
|
|
853
|
+
throw new Error("Malformed PDF hex string");
|
|
854
|
+
position = end + 1;
|
|
855
|
+
return { type: "string", value: content.slice(start, position) };
|
|
856
|
+
}
|
|
857
|
+
if (char === "[") {
|
|
858
|
+
const items = [];
|
|
859
|
+
while (true) {
|
|
860
|
+
whitespace();
|
|
861
|
+
if (content[position] === "]") {
|
|
862
|
+
position++;
|
|
863
|
+
break;
|
|
864
|
+
}
|
|
865
|
+
if (position >= content.length) throw new Error("Unclosed PDF array");
|
|
866
|
+
items.push(value2(depth + 1));
|
|
867
|
+
}
|
|
868
|
+
return items;
|
|
869
|
+
}
|
|
870
|
+
if (char === "/") {
|
|
871
|
+
while (position < content.length && !/[\s\0()[\]<>/%]/.test(content[position]))
|
|
872
|
+
position++;
|
|
873
|
+
return {
|
|
874
|
+
type: "name",
|
|
875
|
+
value: content.slice(start + 1, position).replace(
|
|
876
|
+
/#([\da-f]{2})/gi,
|
|
877
|
+
(_, hex) => String.fromCharCode(parseInt(hex, 16))
|
|
878
|
+
)
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
if (/[\]<>)]/.test(char)) throw new Error("Unexpected PDF delimiter");
|
|
882
|
+
while (position < content.length && !/[\s\0()[\]<>/%]/.test(content[position]))
|
|
883
|
+
position++;
|
|
884
|
+
const token = content.slice(start, position);
|
|
885
|
+
if (/^[+-]?(?:\d+\.?\d*|\.\d+)$/.test(token)) {
|
|
886
|
+
const number = Number(token);
|
|
887
|
+
if (!Number.isFinite(number)) throw new Error("Nonfinite PDF coordinate");
|
|
888
|
+
return number;
|
|
889
|
+
}
|
|
890
|
+
if (["true", "false", "null"].includes(token))
|
|
891
|
+
return { type: "literal", value: token };
|
|
892
|
+
if (!token) throw new Error("Empty PDF token");
|
|
893
|
+
return { type: "operator", value: token };
|
|
894
|
+
};
|
|
895
|
+
while (true) {
|
|
896
|
+
whitespace();
|
|
897
|
+
if (position >= content.length) return;
|
|
898
|
+
yield value2();
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// src/processing/documents/pdf-geometry.mjs
|
|
903
|
+
var finite = (values) => {
|
|
904
|
+
if (!values.every(Number.isFinite))
|
|
905
|
+
throw new Error("Nonfinite transformed PDF geometry");
|
|
906
|
+
return values;
|
|
907
|
+
};
|
|
908
|
+
var identity = [1, 0, 0, 1, 0, 0];
|
|
909
|
+
var product = (a, b) => finite([
|
|
910
|
+
a[0] * b[0] + a[2] * b[1],
|
|
911
|
+
a[1] * b[0] + a[3] * b[1],
|
|
912
|
+
a[0] * b[2] + a[2] * b[3],
|
|
913
|
+
a[1] * b[2] + a[3] * b[3],
|
|
914
|
+
a[0] * b[4] + a[2] * b[5] + a[4],
|
|
915
|
+
a[1] * b[4] + a[3] * b[5] + a[5]
|
|
916
|
+
]);
|
|
917
|
+
var transform = (m, x, y) => finite([m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]]);
|
|
918
|
+
var defaultState = () => ({
|
|
919
|
+
matrix: identity,
|
|
920
|
+
clips: [],
|
|
921
|
+
width: 1,
|
|
922
|
+
cap: 0,
|
|
923
|
+
join: 0,
|
|
924
|
+
miter: 10,
|
|
925
|
+
dash: [],
|
|
926
|
+
offset: 0
|
|
927
|
+
});
|
|
928
|
+
var textOperators = /* @__PURE__ */ new Set([
|
|
929
|
+
"Tf",
|
|
930
|
+
"Tm",
|
|
931
|
+
"Td",
|
|
932
|
+
"TD",
|
|
933
|
+
"Tj",
|
|
934
|
+
"TJ",
|
|
935
|
+
"T*",
|
|
936
|
+
"Ts",
|
|
937
|
+
"Tc",
|
|
938
|
+
"Tw",
|
|
939
|
+
"Tz",
|
|
940
|
+
"TL",
|
|
941
|
+
"'",
|
|
942
|
+
'"'
|
|
943
|
+
]);
|
|
944
|
+
var numericStyles = { rg: 3, RG: 3, g: 1, G: 1, k: 4, K: 4, i: 1 };
|
|
945
|
+
function extractDocumentPaths(content, box, options = {}) {
|
|
946
|
+
if (![box.x, box.y, box.width, box.height].every(Number.isFinite) || box.width <= 0 || box.height <= 0)
|
|
947
|
+
throw new Error("Invalid PDF CropBox");
|
|
948
|
+
const pageMatrix = [1, 0, 0, -1, -box.x, box.y + box.height];
|
|
949
|
+
const paths = [], scopes = [], saved = [];
|
|
950
|
+
let state = defaultState(), args = [], commands = [], pendingClip = null, inText = false;
|
|
951
|
+
let current = null, start = null, pathIdentity = null, images = 0, unownedPaths = 0;
|
|
952
|
+
const owner = () => scopes.findLast((name) => /^Element\d+$/.test(name))?.slice(7) ?? null;
|
|
953
|
+
const region = () => scopes.findLast((name) => /^ViewRegion[\w-]+$/.test(name)) ?? null;
|
|
954
|
+
const numbers = (count) => {
|
|
955
|
+
if (args.length !== count || args.some((item) => typeof item !== "number" || !Number.isFinite(item)))
|
|
956
|
+
throw new Error("Invalid numeric operands");
|
|
957
|
+
return args;
|
|
958
|
+
};
|
|
959
|
+
const emptyPath = () => {
|
|
960
|
+
if (commands.length || pendingClip)
|
|
961
|
+
throw new Error("Path crosses a scope or transform boundary");
|
|
962
|
+
};
|
|
963
|
+
const point = (x, y) => transform(product(pageMatrix, state.matrix), x, y);
|
|
964
|
+
const begin = () => {
|
|
965
|
+
if (inText) throw new Error("Path inside text object");
|
|
966
|
+
pathIdentity ??= { owner: owner(), region: region() };
|
|
967
|
+
};
|
|
968
|
+
const move = (x, y) => {
|
|
969
|
+
begin();
|
|
970
|
+
current = point(x, y);
|
|
971
|
+
start = current;
|
|
972
|
+
commands.push(`M${current.join(" ")}`);
|
|
973
|
+
};
|
|
974
|
+
const line = (x, y) => {
|
|
975
|
+
if (!current) throw new Error("Line has no current point");
|
|
976
|
+
current = point(x, y);
|
|
977
|
+
commands.push(`L${current.join(" ")}`);
|
|
978
|
+
};
|
|
979
|
+
const close = () => {
|
|
980
|
+
if (!current) throw new Error("Close has no current point");
|
|
981
|
+
commands.push("Z");
|
|
982
|
+
current = start;
|
|
983
|
+
};
|
|
984
|
+
const paint = (kind, fillRule) => {
|
|
985
|
+
if (!commands.length) return;
|
|
986
|
+
if (!pathIdentity.owner) {
|
|
987
|
+
unownedPaths++;
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
const result = {
|
|
991
|
+
...pathIdentity,
|
|
992
|
+
d: commands.join(" "),
|
|
993
|
+
kind,
|
|
994
|
+
fillRule,
|
|
995
|
+
strokeWidth: 0,
|
|
996
|
+
clips: state.clips
|
|
997
|
+
};
|
|
998
|
+
if (kind === "stroke") {
|
|
999
|
+
const [a, b, c, d] = state.matrix;
|
|
1000
|
+
const sx = Math.hypot(a, b), sy = Math.hypot(c, d);
|
|
1001
|
+
if (!sx || Math.abs(sx - sy) > 1e-9 * Math.max(sx, sy) || Math.abs(a * c + b * d) > 1e-9 * sx * sy)
|
|
1002
|
+
throw new Error("Nonuniform or skewed stroke transform is unsupported");
|
|
1003
|
+
if (state.width <= 0)
|
|
1004
|
+
throw new Error("Device-dependent hairline stroke is unsupported");
|
|
1005
|
+
finite([
|
|
1006
|
+
state.width * sx,
|
|
1007
|
+
state.offset * sx,
|
|
1008
|
+
...state.dash.map((n) => n * sx)
|
|
1009
|
+
]);
|
|
1010
|
+
Object.assign(result, {
|
|
1011
|
+
strokeWidth: state.width * sx,
|
|
1012
|
+
strokeLinecap: ["butt", "round", "square"][state.cap],
|
|
1013
|
+
strokeLinejoin: ["miter", "round", "bevel"][state.join],
|
|
1014
|
+
strokeMiterlimit: state.miter,
|
|
1015
|
+
strokeDasharray: state.dash.map((n) => n * sx),
|
|
1016
|
+
strokeDashoffset: state.offset * sx
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
paths.push(result);
|
|
1020
|
+
};
|
|
1021
|
+
const finish = () => {
|
|
1022
|
+
if (pendingClip)
|
|
1023
|
+
state = {
|
|
1024
|
+
...state,
|
|
1025
|
+
clips: [
|
|
1026
|
+
...state.clips,
|
|
1027
|
+
{ d: commands.join(" "), fillRule: pendingClip }
|
|
1028
|
+
]
|
|
1029
|
+
};
|
|
1030
|
+
commands = [];
|
|
1031
|
+
current = null;
|
|
1032
|
+
start = null;
|
|
1033
|
+
pathIdentity = null;
|
|
1034
|
+
pendingClip = null;
|
|
1035
|
+
};
|
|
1036
|
+
for (const token of pdfTokens(content)) {
|
|
1037
|
+
if (token?.type !== "operator") {
|
|
1038
|
+
args.push(token);
|
|
1039
|
+
continue;
|
|
1040
|
+
}
|
|
1041
|
+
const op = token.value;
|
|
1042
|
+
if (Object.hasOwn(numericStyles, op)) numbers(numericStyles[op]);
|
|
1043
|
+
else if (textOperators.has(op)) {
|
|
1044
|
+
if (!inText) throw new Error("Text operation outside text object");
|
|
1045
|
+
} else
|
|
1046
|
+
switch (op) {
|
|
1047
|
+
case "BMC":
|
|
1048
|
+
case "BDC": {
|
|
1049
|
+
emptyPath();
|
|
1050
|
+
if (args.length !== (op === "BMC" ? 1 : 2) || args[0]?.type !== "name")
|
|
1051
|
+
throw new Error("Malformed marked content");
|
|
1052
|
+
if (/^Element\d+$/.test(args[0].value) && owner())
|
|
1053
|
+
throw new Error("Nested element ownership");
|
|
1054
|
+
if (op === "BDC" && !["name", "dictionary"].includes(args[1]?.type))
|
|
1055
|
+
throw new Error("Malformed marked content properties");
|
|
1056
|
+
if (args[0].value === "OC")
|
|
1057
|
+
throw new Error("Optional-content visibility is unsupported");
|
|
1058
|
+
scopes.push(args[0].value);
|
|
1059
|
+
break;
|
|
1060
|
+
}
|
|
1061
|
+
case "EMC":
|
|
1062
|
+
emptyPath();
|
|
1063
|
+
numbers(0);
|
|
1064
|
+
if (!scopes.pop()) throw new Error("Unbalanced marked content");
|
|
1065
|
+
break;
|
|
1066
|
+
case "q":
|
|
1067
|
+
emptyPath();
|
|
1068
|
+
numbers(0);
|
|
1069
|
+
saved.push({ ...state });
|
|
1070
|
+
break;
|
|
1071
|
+
case "Q":
|
|
1072
|
+
emptyPath();
|
|
1073
|
+
numbers(0);
|
|
1074
|
+
if (!saved.length) throw new Error("Unbalanced graphics state");
|
|
1075
|
+
state = saved.pop();
|
|
1076
|
+
break;
|
|
1077
|
+
case "cm":
|
|
1078
|
+
emptyPath();
|
|
1079
|
+
state = { ...state, matrix: product(state.matrix, numbers(6)) };
|
|
1080
|
+
break;
|
|
1081
|
+
case "w":
|
|
1082
|
+
;
|
|
1083
|
+
[state.width] = numbers(1);
|
|
1084
|
+
if (state.width < 0) throw new Error("Negative stroke width");
|
|
1085
|
+
break;
|
|
1086
|
+
case "J":
|
|
1087
|
+
case "j": {
|
|
1088
|
+
const [n] = numbers(1);
|
|
1089
|
+
if (![0, 1, 2].includes(n)) throw new Error("Invalid stroke style");
|
|
1090
|
+
state[op === "J" ? "cap" : "join"] = n;
|
|
1091
|
+
break;
|
|
1092
|
+
}
|
|
1093
|
+
case "M":
|
|
1094
|
+
;
|
|
1095
|
+
[state.miter] = numbers(1);
|
|
1096
|
+
if (state.miter < 1) throw new Error("Invalid miter limit");
|
|
1097
|
+
break;
|
|
1098
|
+
case "d":
|
|
1099
|
+
if (args.length !== 2 || !Array.isArray(args[0]) || args[0].some(
|
|
1100
|
+
(n) => typeof n !== "number" || !Number.isFinite(n) || n < 0
|
|
1101
|
+
) || typeof args[1] !== "number" || !Number.isFinite(args[1]) || args[0].length && args[0].every((n) => n === 0))
|
|
1102
|
+
throw new Error("Invalid stroke dash");
|
|
1103
|
+
state.dash = args[0];
|
|
1104
|
+
state.offset = args[1];
|
|
1105
|
+
break;
|
|
1106
|
+
case "m":
|
|
1107
|
+
move(...numbers(2));
|
|
1108
|
+
break;
|
|
1109
|
+
case "l":
|
|
1110
|
+
line(...numbers(2));
|
|
1111
|
+
break;
|
|
1112
|
+
case "h":
|
|
1113
|
+
numbers(0);
|
|
1114
|
+
close();
|
|
1115
|
+
break;
|
|
1116
|
+
case "re": {
|
|
1117
|
+
const [x, y, w, h] = numbers(4);
|
|
1118
|
+
move(x, y);
|
|
1119
|
+
line(x + w, y);
|
|
1120
|
+
line(x + w, y + h);
|
|
1121
|
+
line(x, y + h);
|
|
1122
|
+
close();
|
|
1123
|
+
break;
|
|
1124
|
+
}
|
|
1125
|
+
case "c":
|
|
1126
|
+
case "v":
|
|
1127
|
+
case "y": {
|
|
1128
|
+
if (!current) throw new Error("Curve has no current point");
|
|
1129
|
+
const values = numbers(op === "c" ? 6 : 4);
|
|
1130
|
+
const points = [];
|
|
1131
|
+
for (let i = 0; i < values.length; i += 2)
|
|
1132
|
+
points.push(point(values[i], values[i + 1]));
|
|
1133
|
+
if (op === "v") points.unshift(current);
|
|
1134
|
+
if (op === "y") points.push(points[1]);
|
|
1135
|
+
commands.push(`C${points.flat().join(" ")}`);
|
|
1136
|
+
current = points[2];
|
|
1137
|
+
break;
|
|
1138
|
+
}
|
|
1139
|
+
case "W":
|
|
1140
|
+
case "W*":
|
|
1141
|
+
numbers(0);
|
|
1142
|
+
if (!commands.length || pendingClip)
|
|
1143
|
+
throw new Error("Invalid clipping path");
|
|
1144
|
+
pendingClip = op === "W*" ? "evenodd" : "nonzero";
|
|
1145
|
+
break;
|
|
1146
|
+
case "f":
|
|
1147
|
+
case "F":
|
|
1148
|
+
case "f*":
|
|
1149
|
+
case "S":
|
|
1150
|
+
case "s":
|
|
1151
|
+
case "B":
|
|
1152
|
+
case "B*":
|
|
1153
|
+
case "b":
|
|
1154
|
+
case "b*":
|
|
1155
|
+
case "n":
|
|
1156
|
+
numbers(0);
|
|
1157
|
+
if (["s", "b", "b*"].includes(op)) close();
|
|
1158
|
+
if (!["S", "s", "n"].includes(op))
|
|
1159
|
+
paint("fill", op.endsWith("*") ? "evenodd" : "nonzero");
|
|
1160
|
+
if (["S", "s", "B", "B*", "b", "b*"].includes(op))
|
|
1161
|
+
paint("stroke", "nonzero");
|
|
1162
|
+
finish();
|
|
1163
|
+
break;
|
|
1164
|
+
case "BT":
|
|
1165
|
+
numbers(0);
|
|
1166
|
+
emptyPath();
|
|
1167
|
+
if (inText) throw new Error("Nested text object");
|
|
1168
|
+
inText = true;
|
|
1169
|
+
break;
|
|
1170
|
+
case "ET":
|
|
1171
|
+
numbers(0);
|
|
1172
|
+
if (!inText) throw new Error("Unbalanced text object");
|
|
1173
|
+
inText = false;
|
|
1174
|
+
break;
|
|
1175
|
+
case "Tr": {
|
|
1176
|
+
const [n] = numbers(1);
|
|
1177
|
+
if (!inText || ![0, 1, 2, 3].includes(n))
|
|
1178
|
+
throw new Error("Text clipping is unsupported");
|
|
1179
|
+
break;
|
|
1180
|
+
}
|
|
1181
|
+
case "Do":
|
|
1182
|
+
emptyPath();
|
|
1183
|
+
if (args.length !== 1 || args[0]?.type !== "name")
|
|
1184
|
+
throw new Error("Invalid XObject reference");
|
|
1185
|
+
if (owner() || options.xObjects?.[args[0].value] !== "Image")
|
|
1186
|
+
throw new Error(
|
|
1187
|
+
`Uninterpreted identified or non-image XObject: ${args[0].value}`
|
|
1188
|
+
);
|
|
1189
|
+
images++;
|
|
1190
|
+
break;
|
|
1191
|
+
case "ri":
|
|
1192
|
+
if (args.length !== 1 || args[0]?.type !== "name")
|
|
1193
|
+
throw new Error("Invalid rendering intent");
|
|
1194
|
+
break;
|
|
1195
|
+
default:
|
|
1196
|
+
throw new Error(`Unsupported PDF operator: ${op}`);
|
|
1197
|
+
}
|
|
1198
|
+
args = [];
|
|
1199
|
+
}
|
|
1200
|
+
if (saved.length || scopes.length || inText || commands.length || args.length)
|
|
1201
|
+
throw new Error("Unclosed PDF content");
|
|
1202
|
+
return {
|
|
1203
|
+
paths,
|
|
1204
|
+
diagnostics: [
|
|
1205
|
+
`${images} raster image paints retained only in the original document.`,
|
|
1206
|
+
`${unownedPaths} unowned vector paths excluded.`,
|
|
1207
|
+
"Document element tags identify exported drawings; they do not establish IFC identity or WBS relationships."
|
|
1208
|
+
]
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
async function readDocumentGeometry(bytes, { pageNumber = 1 } = {}) {
|
|
1212
|
+
let viewBox = null;
|
|
1213
|
+
try {
|
|
1214
|
+
const pdf = await PDFDocument2.load(bytes);
|
|
1215
|
+
const page = pdf.getPages()[pageNumber - 1];
|
|
1216
|
+
if (!page) throw new Error(`Missing PDF page ${pageNumber}`);
|
|
1217
|
+
if (page.getRotation().angle !== 0)
|
|
1218
|
+
throw new Error("Rotated PDF page is unsupported");
|
|
1219
|
+
const unit = page.node.lookup(PDFName2.of("UserUnit"));
|
|
1220
|
+
if (unit && unit.asNumber() !== 1)
|
|
1221
|
+
throw new Error("Non-default PDF UserUnit is unsupported");
|
|
1222
|
+
const crop = page.getCropBox(), media = page.getMediaBox();
|
|
1223
|
+
const x = Math.max(crop.x, media.x), y = Math.max(crop.y, media.y);
|
|
1224
|
+
const box = {
|
|
1225
|
+
x,
|
|
1226
|
+
y,
|
|
1227
|
+
width: Math.min(crop.x + crop.width, media.x + media.width) - x,
|
|
1228
|
+
height: Math.min(crop.y + crop.height, media.y + media.height) - y
|
|
1229
|
+
};
|
|
1230
|
+
if (box.width <= 0 || box.height <= 0)
|
|
1231
|
+
throw new Error("CropBox has no intersection with MediaBox");
|
|
1232
|
+
viewBox = [0, 0, box.width, box.height];
|
|
1233
|
+
const raw = page.node.Contents();
|
|
1234
|
+
const streams = raw instanceof PDFArray ? Array.from({ length: raw.size() }, (_, i) => raw.lookup(i)) : [raw];
|
|
1235
|
+
const content = streams.map(
|
|
1236
|
+
(stream) => new TextDecoder("latin1").decode(decodePDFRawStream2(stream).decode())
|
|
1237
|
+
).join("\n");
|
|
1238
|
+
const objects = page.node.Resources()?.lookup(PDFName2.of("XObject"));
|
|
1239
|
+
const xObjects = Object.fromEntries(
|
|
1240
|
+
(objects?.keys() ?? []).map((key) => [
|
|
1241
|
+
key.decodeText(),
|
|
1242
|
+
objects.lookup(key)?.dict?.lookup(PDFName2.of("Subtype"))?.toString().slice(1)
|
|
1243
|
+
])
|
|
1244
|
+
);
|
|
1245
|
+
return { supported: true, viewBox, ...extractDocumentPaths(content, box, { xObjects }) };
|
|
1246
|
+
} catch (error) {
|
|
1247
|
+
return {
|
|
1248
|
+
supported: false,
|
|
1249
|
+
viewBox,
|
|
1250
|
+
paths: [],
|
|
1251
|
+
diagnostics: [`Document-only: ${error.message}`]
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
function requireSupportedDrawing(drawing, viewId) {
|
|
1256
|
+
if (drawing.supported !== true || !drawing.viewBox)
|
|
1257
|
+
throw new Error(`Unsupported or invalid PDF view ${viewId}: ${drawing.diagnostics.join("; ")}. Existing prepared artifacts were not replaced.`);
|
|
1258
|
+
return drawing;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
// src/processing/documents/representations.mjs
|
|
1262
|
+
function linkRepresentations({ drawing, elements, viewId, source, modelSource, page }) {
|
|
1263
|
+
const { byTag } = indexModelIdentity(elements);
|
|
1264
|
+
const clips = [], clipIds = /* @__PURE__ */ new Map(), pieces = /* @__PURE__ */ new Map(), unknownOwners = /* @__PURE__ */ new Set();
|
|
1265
|
+
for (const path of drawing.paths) {
|
|
1266
|
+
const element = byTag.get(path.owner);
|
|
1267
|
+
if (!element) {
|
|
1268
|
+
unknownOwners.add(path.owner);
|
|
1269
|
+
continue;
|
|
1270
|
+
}
|
|
1271
|
+
const { owner: _owner, region: _region, ...geometry } = path;
|
|
1272
|
+
const clipIndexes = path.clips.map((clip) => {
|
|
1273
|
+
const key = JSON.stringify(clip);
|
|
1274
|
+
if (!clipIds.has(key)) {
|
|
1275
|
+
clipIds.set(key, clips.length);
|
|
1276
|
+
clips.push(clip);
|
|
1277
|
+
}
|
|
1278
|
+
return clipIds.get(key);
|
|
1279
|
+
});
|
|
1280
|
+
if (!pieces.has(element.id)) pieces.set(element.id, { id: element.id, elementId: element.elementId, paths: [] });
|
|
1281
|
+
pieces.get(element.id).paths.push({ ...geometry, clips: clipIndexes });
|
|
1282
|
+
}
|
|
1283
|
+
return {
|
|
1284
|
+
geometry: {
|
|
1285
|
+
schemaVersion: 1,
|
|
1286
|
+
viewId,
|
|
1287
|
+
viewBox: drawing.viewBox,
|
|
1288
|
+
source: { sha256: source.sha256, page },
|
|
1289
|
+
modelSource: { sha256: modelSource.sha256 },
|
|
1290
|
+
clips,
|
|
1291
|
+
pieces: [...pieces.values()]
|
|
1292
|
+
},
|
|
1293
|
+
audit: {
|
|
1294
|
+
unknownOwners: [...unknownOwners].toSorted(),
|
|
1295
|
+
diagnostics: drawing.diagnostics,
|
|
1296
|
+
rawPaths: drawing.paths.length,
|
|
1297
|
+
representations: [...pieces.values()].map((p) => ({
|
|
1298
|
+
globalId: p.id,
|
|
1299
|
+
elementId: p.elementId,
|
|
1300
|
+
paths: p.paths.length,
|
|
1301
|
+
fills: p.paths.filter((path) => path.kind === "fill").length,
|
|
1302
|
+
strokes: p.paths.filter((path) => path.kind === "stroke").length,
|
|
1303
|
+
graphicDecision: null
|
|
1304
|
+
}))
|
|
1305
|
+
}
|
|
1306
|
+
};
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
// src/processing/documents/admission.mjs
|
|
1310
|
+
function requireInteractiveView({ drawing, geometry, viewId }) {
|
|
1311
|
+
const reason = drawing.paths.length === 0 ? "No identified vector paths were extracted; a raster or untagged PDF cannot supply interactive geometry" : geometry.pieces.length === 0 ? "No identified PDF paths match an explicit IFC element identity" : null;
|
|
1312
|
+
if (reason) {
|
|
1313
|
+
throw new Error(`Interactive view ${viewId} rejected: ${reason}. ${drawing.diagnostics.join("; ")}. Existing prepared artifacts were not replaced.`);
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// src/processing/model/comparable-quantity.mjs
|
|
1318
|
+
var units = { ML: "m", M: "m", M2: "m\xB2", "M\xB2": "m\xB2", M3: "m\xB3", "M\xB3": "m\xB3", PZA: "pza", PCS: "pza" };
|
|
1319
|
+
var unitOf = (value2) => units[String(value2 ?? "").trim().toUpperCase()] ?? null;
|
|
1320
|
+
var labelsByUnit = {
|
|
1321
|
+
m: ["Longitud"],
|
|
1322
|
+
"m\xB3": ["Volumen neto exportado", "Volumen exportado", "Volumen bruto exportado"]
|
|
1323
|
+
};
|
|
1324
|
+
function areaLabels(category) {
|
|
1325
|
+
if (category.toUpperCase().startsWith("IFCWALL")) return [
|
|
1326
|
+
"\xC1rea lateral neta exportada",
|
|
1327
|
+
"\xC1rea neta exportada",
|
|
1328
|
+
"\xC1rea lateral bruta exportada",
|
|
1329
|
+
"\xC1rea bruta exportada"
|
|
1330
|
+
];
|
|
1331
|
+
if (/^IFC(?:SLAB|ROOF|COVERING)$/.test(category.toUpperCase())) return [
|
|
1332
|
+
"\xC1rea neta exportada",
|
|
1333
|
+
"\xC1rea de huella neta exportada",
|
|
1334
|
+
"\xC1rea bruta exportada",
|
|
1335
|
+
"\xC1rea de huella bruta exportada"
|
|
1336
|
+
];
|
|
1337
|
+
return ["\xC1rea neta exportada", "\xC1rea bruta exportada"];
|
|
1338
|
+
}
|
|
1339
|
+
function pending(unit, reason) {
|
|
1340
|
+
return { value: null, unit: unit ?? "", label: "Cantidad comparable pendiente", reason };
|
|
1341
|
+
}
|
|
1342
|
+
function comparableQuantity(element, concept) {
|
|
1343
|
+
const unit = unitOf(concept.unit);
|
|
1344
|
+
if (!unit) return pending(String(concept.unit ?? ""), "La unidad de la partida no tiene una regla de comparaci\xF3n con una pieza IFC.");
|
|
1345
|
+
if (unit === "pza") {
|
|
1346
|
+
if (!/^[0-3][0-9A-Za-z_$]{21}$/.test(element.id ?? "")) return pending(unit, "La pieza no tiene una identidad IFC verificable.");
|
|
1347
|
+
return { value: 1, unit, label: "Elemento identificado", source: `IFC GlobalId ${element.id}`, reason: "Conteo de una entidad IFC seleccionada; no concilia piezas presupuestadas ni componentes de un conjunto." };
|
|
1348
|
+
}
|
|
1349
|
+
const labels3 = unit === "m\xB2" ? areaLabels(element.category ?? "") : labelsByUnit[unit];
|
|
1350
|
+
for (const label2 of labels3) {
|
|
1351
|
+
const candidates = (element.measurements ?? []).filter((measure) => measure.label === label2);
|
|
1352
|
+
if (!candidates.length) continue;
|
|
1353
|
+
if (candidates.some((measure) => measure.unit !== unit || !Number.isFinite(measure.value) || measure.value < 0 || !measure.source)) {
|
|
1354
|
+
return pending(unit, `${label2}: la unidad, el valor o su procedencia no permiten una comparaci\xF3n verificable.`);
|
|
1355
|
+
}
|
|
1356
|
+
if (new Set(candidates.map((measure) => measure.value)).size > 1) {
|
|
1357
|
+
return pending(unit, `${label2}: existen valores exportados distintos; hace falta confirmar cu\xE1l corresponde a esta pieza.`);
|
|
1358
|
+
}
|
|
1359
|
+
const instance = candidates.filter((measure) => measure.source.startsWith("Instancia"));
|
|
1360
|
+
if (!instance.length) return pending(unit, `${label2}: solo existen propiedades de tipo; no se asumen como cantidad de esta pieza.`);
|
|
1361
|
+
return {
|
|
1362
|
+
value: instance[0].value,
|
|
1363
|
+
unit,
|
|
1364
|
+
label: label2,
|
|
1365
|
+
source: [...new Set(instance.map((measure) => measure.source))].toSorted().join(" | "),
|
|
1366
|
+
reason: "Medida exportada de la pieza para comparar como referencia; no es una cantidad conciliada con el presupuesto."
|
|
1367
|
+
};
|
|
1368
|
+
}
|
|
1369
|
+
if (unit === "m\xB2" && (element.measurements ?? []).some((measure) => measure.label === "\xC1rea exportada")) {
|
|
1370
|
+
return pending(unit, "El IFC aporta un \xE1rea sin identificar si es neta o bruta. No se asume su naturaleza ni se descuentan huecos.");
|
|
1371
|
+
}
|
|
1372
|
+
return pending(unit, unit === "m" ? "No hay longitud de instancia exportada. No se sustituyen longitudes nominales ni se calculan dimensiones." : "No hay una medida exportada compatible. No se multiplican dimensiones ni se descuentan huecos para inventarla.");
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
// src/processing/houses/materials-relations.mjs
|
|
1376
|
+
var add = (map, key, value2) => map.set(key, [...map.get(key) ?? [], value2]);
|
|
1377
|
+
var units2 = { ML: "m", M2: "m\xB2", M3: "m\xB3", PZA: "pza" };
|
|
1378
|
+
var normalizeUnit = (unit) => String(unit ?? "").toUpperCase().replace("\xB2", "2").replace("\xB3", "3");
|
|
1379
|
+
var conflictingQuantities = (entries) => ["ML", "M2", "M3", "PZA"].some((key) => new Set(entries.flatMap((entry) => entry.quantities.filter((quantity) => quantity.unit === key).map((quantity) => quantity.value))).size > 1);
|
|
1380
|
+
var pendingQuantity = (unit, reason) => ({ value: null, unit, label: "Cantidad exportada no disponible", reason });
|
|
1381
|
+
function workbookQuantity(entries, concept) {
|
|
1382
|
+
const unit = normalizeUnit(concept.unit), displayUnit = units2[unit] ?? concept.unit;
|
|
1383
|
+
if (!entries.length || entries.some((entry) => entry.quantityRole !== "exported")) return pendingQuantity(displayUnit, "Registro documental o cantidad sin alcance definido; no se convierte ni se cuenta como una pieza o lote.");
|
|
1384
|
+
const values = entries.flatMap((entry) => entry.quantities.filter((quantity) => quantity.unit === unit).map((quantity) => ({ ...quantity, source: entry.source })));
|
|
1385
|
+
if (!values.length) return pendingQuantity(displayUnit, "El Excel no contiene una cantidad exportada compatible con la unidad de esta partida.");
|
|
1386
|
+
const conflicts = conflictingQuantities(entries);
|
|
1387
|
+
if (conflicts || entries.some((entry) => entry.issues.includes("conflicting-host-material"))) return pendingQuantity(displayUnit, "Hay cantidades exportadas contradictorias; se conservan las filas y no se suman ni se elige una silenciosamente.");
|
|
1388
|
+
return {
|
|
1389
|
+
value: values[0].value,
|
|
1390
|
+
unit: displayUnit,
|
|
1391
|
+
label: entries[0].recordKind === "material" ? "Cantidad del material \xB7 Excel" : "Cantidad de la pieza \xB7 Excel",
|
|
1392
|
+
source: values.map((value2) => `${value2.source.name} \xB7 ${value2.source.sheet} \xB7 fila ${value2.source.row} \xB7 SHA-256 ${value2.source.sha256}`).join(" | "),
|
|
1393
|
+
reason: "Cantidad documental exportada para esta relaci\xF3n. No se obtiene del \xE1rea del anfitri\xF3n ni se reparte entre capas."
|
|
1394
|
+
};
|
|
1395
|
+
}
|
|
1396
|
+
function linkDeliveryRelations(elements, concepts, review, modelHash) {
|
|
1397
|
+
const byId = new Map(concepts.map((concept) => [concept.id, concept]));
|
|
1398
|
+
return elements.map((element) => {
|
|
1399
|
+
const rows = review?.elementBindings.get(element.id) ?? [], apps = review?.applications.get(element.id) ?? [];
|
|
1400
|
+
const declarations = [...rows, ...apps], linked = declarations.filter((entry) => entry.matchStatus === "unique");
|
|
1401
|
+
const ifcEvidence = { kind: "ifc", sha256: modelHash, rule: "exact-exported-WBS" };
|
|
1402
|
+
const relations = /* @__PURE__ */ new Map();
|
|
1403
|
+
const put = (relation) => {
|
|
1404
|
+
const old = relations.get(relation.conceptId);
|
|
1405
|
+
if (!old) {
|
|
1406
|
+
relations.set(relation.conceptId, relation);
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
relations.set(relation.conceptId, {
|
|
1410
|
+
...old,
|
|
1411
|
+
kind: old.kind === "element" || relation.kind === "element" ? "element" : "material",
|
|
1412
|
+
status: old.status === "pending" || relation.status === "pending" ? "pending" : "linked",
|
|
1413
|
+
ambiguous: old.ambiguous || relation.ambiguous,
|
|
1414
|
+
evidence: [...old.evidence, ...relation.evidence],
|
|
1415
|
+
issues: [.../* @__PURE__ */ new Set([...old.issues, ...relation.issues])]
|
|
1416
|
+
});
|
|
1417
|
+
};
|
|
1418
|
+
const ifcUnique = [];
|
|
1419
|
+
for (const code of element.wbs ?? []) {
|
|
1420
|
+
const candidates = concepts.filter((concept) => concept.code === code || concept.qualifiedCode === code);
|
|
1421
|
+
if (candidates.length === 1) ifcUnique.push(candidates[0]);
|
|
1422
|
+
const explicit = linked.filter((entry) => candidates.some((concept) => concept.id === entry.candidates[0].id));
|
|
1423
|
+
if (candidates.length > 1 && explicit.length) continue;
|
|
1424
|
+
for (const concept of candidates) put({ conceptId: concept.id, kind: "element", status: candidates.length === 1 ? "linked" : "pending", ambiguous: candidates.length > 1, evidence: [ifcEvidence], issues: candidates.length > 1 ? ["wbs-ambiguous"] : [] });
|
|
1425
|
+
}
|
|
1426
|
+
const elementGroups = /* @__PURE__ */ new Map();
|
|
1427
|
+
for (const row of rows.filter((entry) => entry.matchStatus === "unique")) add(elementGroups, byId.get(row.candidates[0].id).code, row);
|
|
1428
|
+
const conflictingRows = /* @__PURE__ */ new Set();
|
|
1429
|
+
for (const group of elementGroups.values()) if (new Set(group.map((row) => row.candidates[0].id)).size > 1) for (const row of group) conflictingRows.add(row.id);
|
|
1430
|
+
for (const entry of declarations) {
|
|
1431
|
+
const uniqueConcept = entry.matchStatus === "unique" ? byId.get(entry.candidates[0].id) : null;
|
|
1432
|
+
const conflictingIfc = uniqueConcept && entry.recordKind === "element" ? ifcUnique.filter((concept) => concept.id !== uniqueConcept.id) : [];
|
|
1433
|
+
const conflict = conflictingRows.has(entry.id) || conflictingIfc.length > 0;
|
|
1434
|
+
if (conflict) {
|
|
1435
|
+
entry.relationStatus = "pending";
|
|
1436
|
+
entry.issues.push("conflicting-element-relation");
|
|
1437
|
+
for (const concept of conflictingIfc) {
|
|
1438
|
+
const relation = relations.get(concept.id);
|
|
1439
|
+
relation.status = "pending";
|
|
1440
|
+
relation.issues.push("conflicting-element-relation");
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
for (const candidate of entry.candidates) put({
|
|
1444
|
+
conceptId: candidate.id,
|
|
1445
|
+
kind: entry.recordKind,
|
|
1446
|
+
status: entry.matchStatus === "unique" && !conflict ? "linked" : "pending",
|
|
1447
|
+
ambiguous: entry.matchStatus !== "unique" || conflict,
|
|
1448
|
+
evidence: [entry.evidence],
|
|
1449
|
+
issues: conflict ? ["conflicting-element-relation"] : entry.matchStatus !== "unique" ? ["wbs-ambiguous"] : []
|
|
1450
|
+
});
|
|
1451
|
+
}
|
|
1452
|
+
const related = { ...element, relations: [...relations.values()], ...apps.length ? { materialApplications: apps } : {} };
|
|
1453
|
+
const named = withDisplayNames([related], concepts)[0];
|
|
1454
|
+
const quantitiesByConcept = {}, namesByConcept = { ...named.namesByConcept };
|
|
1455
|
+
for (const relation of related.relations) {
|
|
1456
|
+
const concept = byId.get(relation.conceptId);
|
|
1457
|
+
if (relation.status === "linked") namesByConcept[concept.id] = conceptName(concept.description);
|
|
1458
|
+
const relevantRows = rows.filter((row) => row.matchStatus === "unique" && row.candidates[0].id === concept.id);
|
|
1459
|
+
const relevantApps = apps.filter((app) => app.matchStatus === "unique" && app.candidates[0].id === concept.id);
|
|
1460
|
+
if (relation.kind === "material") {
|
|
1461
|
+
quantitiesByConcept[concept.id] = relevantApps.length > 1 ? pendingQuantity(units2[normalizeUnit(concept.unit)] ?? concept.unit, "Varias aplicaciones comparten la partida; consulta sus cantidades individuales en Materiales y acabados. No se suman autom\xE1ticamente.") : workbookQuantity(relevantApps, concept);
|
|
1462
|
+
} else {
|
|
1463
|
+
const fromIfc = comparableQuantity(element, concept);
|
|
1464
|
+
const fromWorkbook = relevantRows.length ? workbookQuantity(relevantRows, concept) : null;
|
|
1465
|
+
if (conflictingQuantities(relevantRows)) {
|
|
1466
|
+
for (const row of relevantRows) row.issues.push("conflicting-element-quantity");
|
|
1467
|
+
relation.issues.push("conflicting-element-quantity");
|
|
1468
|
+
quantitiesByConcept[concept.id] = fromWorkbook;
|
|
1469
|
+
} else if (relevantRows.some((row) => row.quantityRole !== "exported")) quantitiesByConcept[concept.id] = fromWorkbook;
|
|
1470
|
+
else if (fromWorkbook && fromIfc.value !== null && fromWorkbook.value !== null && fromIfc.unit === fromWorkbook.unit && Math.abs(fromIfc.value - fromWorkbook.value) > Math.max(1, Math.abs(fromIfc.value), Math.abs(fromWorkbook.value)) * 1e-9) {
|
|
1471
|
+
for (const row of relevantRows) row.issues.push("ifc-workbook-quantity-discrepancy");
|
|
1472
|
+
relation.issues.push("ifc-workbook-quantity-discrepancy");
|
|
1473
|
+
quantitiesByConcept[concept.id] = pendingQuantity(fromIfc.unit, "IFC y Excel exportan cantidades distintas; pueden tener alcances diferentes. Se conservan ambas fuentes sin conciliarlas ni elegir una silenciosamente.");
|
|
1474
|
+
} else quantitiesByConcept[concept.id] = fromIfc.value !== null || !relevantRows.length ? fromIfc : fromWorkbook;
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
return { ...named, namesByConcept, quantitiesByConcept };
|
|
1478
|
+
});
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
// src/processing/model/compact-measures.mjs
|
|
1482
|
+
var choices = {
|
|
1483
|
+
wall: [["Espesor"], ["Longitud"], ["Altura", "Altura no conectada"]],
|
|
1484
|
+
opening: [["Ancho total", "Ancho"], ["Altura total", "Altura"]],
|
|
1485
|
+
slab: [["\xC1rea neta exportada", "\xC1rea exportada"], ["Espesor", "Espesor predeterminado"]],
|
|
1486
|
+
structural: [["Longitud"], ["Ancho"], ["Altura"]]
|
|
1487
|
+
};
|
|
1488
|
+
function compactMeasurements(element) {
|
|
1489
|
+
const category = element.category.toUpperCase();
|
|
1490
|
+
const groups = category.includes("WALL") ? choices.wall : /IFC(DOOR|WINDOW)$/.test(category) ? choices.opening : /IFC(SLAB|COVERING|ROOF)$/.test(category) ? choices.slab : /IFC(BEAM|COLUMN|MEMBER|FOOTING)$/.test(category) ? choices.structural : [];
|
|
1491
|
+
const result = [];
|
|
1492
|
+
for (const labels3 of groups) {
|
|
1493
|
+
for (const label2 of labels3) {
|
|
1494
|
+
const matches = element.measurements.filter((m) => m.label === label2 && m.value > 0 && Number.isFinite(m.value));
|
|
1495
|
+
if (!matches.length) continue;
|
|
1496
|
+
if (new Set(matches.map((m) => `${m.unit}:${m.value}`)).size !== 1) break;
|
|
1497
|
+
result.push(matches.find((m) => m.source.startsWith("Instancia")) ?? matches[0]);
|
|
1498
|
+
break;
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
return result;
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
// src/processing/houses/materials-workbook.mjs
|
|
1505
|
+
import { unzipSync as unzipSync2, strFromU8 as strFromU82 } from "fflate";
|
|
1506
|
+
import { XMLParser as XMLParser2, XMLValidator } from "fast-xml-parser";
|
|
1507
|
+
var list2 = (value2) => value2 == null ? [] : Array.isArray(value2) ? value2 : [value2];
|
|
1508
|
+
var xml2 = new XMLParser2({ ignoreAttributes: false, parseTagValue: false, trimValues: false });
|
|
1509
|
+
var plain2 = (value2) => typeof value2 === "string" ? value2 : value2?.["#text"] ?? "";
|
|
1510
|
+
var rich2 = (value2) => value2?.t != null ? plain2(value2.t) : list2(value2?.r).map((run) => plain2(run.t)).join("");
|
|
1511
|
+
var headerKey = (value2) => String(value2 ?? "").normalize("NFD").replace(/[\u0300-\u036f]/g, "").trim().toUpperCase().replace(/[\s_:]+/g, "");
|
|
1512
|
+
function readMaterialsSheets(bytes) {
|
|
1513
|
+
let total = 0, count = 0;
|
|
1514
|
+
const archive = unzipSync2(bytes, { filter(entry) {
|
|
1515
|
+
total += entry.originalSize;
|
|
1516
|
+
if (++count > 1e4 || entry.originalSize > 32 * 1024 * 1024 || total > 64 * 1024 * 1024) throw new Error("El Excel de materiales y acabados excede el tama\xF1o admitido (64 MB descomprimido).");
|
|
1517
|
+
return entry.name.startsWith("xl/") && entry.name.endsWith(".xml") || entry.name === "xl/_rels/workbook.xml.rels";
|
|
1518
|
+
} });
|
|
1519
|
+
const parse = (path) => {
|
|
1520
|
+
if (!archive[path]) throw new Error(`Excel de materiales y acabados inv\xE1lido: falta ${path}.`);
|
|
1521
|
+
const content = strFromU82(archive[path]);
|
|
1522
|
+
if (/<!DOCTYPE|<!ENTITY/i.test(content) || XMLValidator.validate(content) !== true) throw new Error("XML inv\xE1lido o entidades personalizadas en el Excel de materiales y acabados.");
|
|
1523
|
+
return xml2.parse(content);
|
|
1524
|
+
};
|
|
1525
|
+
const sheets = list2(parse("xl/workbook.xml").workbook?.sheets?.sheet);
|
|
1526
|
+
const relationships = list2(parse("xl/_rels/workbook.xml.rels").Relationships?.Relationship);
|
|
1527
|
+
const strings = archive["xl/sharedStrings.xml"] ? list2(parse("xl/sharedStrings.xml").sst?.si).map(rich2) : [];
|
|
1528
|
+
if (!sheets.length) throw new Error("El Excel de materiales y acabados no contiene hojas.");
|
|
1529
|
+
if (sheets.some((sheet) => typeof sheet["@_name"] !== "string" || !sheet["@_name"].trim()) || new Set(sheets.map((sheet) => sheet["@_name"])).size !== sheets.length) throw new Error("El Excel requiere nombres de hoja \xFAnicos y no vac\xEDos.");
|
|
1530
|
+
return sheets.map((sheet) => {
|
|
1531
|
+
const relation = relationships.find((item) => item["@_Id"] === sheet["@_r:id"]);
|
|
1532
|
+
const target = relation?.["@_Target"]?.replace(/^\/xl\//, "");
|
|
1533
|
+
if (!/^worksheets\/[^/]+\.xml$/.test(target ?? "") || relation?.["@_TargetMode"] === "External") throw new Error("Referencia de hoja no admitida en el Excel de materiales y acabados.");
|
|
1534
|
+
const parsed = parse(`xl/${target}`);
|
|
1535
|
+
if (!Object.hasOwn(parsed, "worksheet")) throw new Error("Hoja inv\xE1lida en el Excel de materiales y acabados.");
|
|
1536
|
+
return { sheet: sheet["@_name"], rows: list2(parsed.worksheet.sheetData?.row).map((row) => ({
|
|
1537
|
+
row: Number(row["@_r"]),
|
|
1538
|
+
cells: Object.fromEntries(list2(row.c).map((cell) => {
|
|
1539
|
+
let value2 = cell.v ?? null, error = ["e", "b"].includes(cell["@_t"]);
|
|
1540
|
+
if (cell["@_t"] === "s") {
|
|
1541
|
+
value2 = strings[Number(value2)] ?? null;
|
|
1542
|
+
error ||= value2 === null;
|
|
1543
|
+
} else if (cell["@_t"] === "inlineStr") value2 = rich2(cell.is);
|
|
1544
|
+
else if (value2 != null && !["str", "e"].includes(cell["@_t"])) value2 = Number(value2);
|
|
1545
|
+
return [String(cell["@_r"]).replace(/\d+$/, ""), { value: value2, formula: cell.f == null ? null : plain2(cell.f), error }];
|
|
1546
|
+
}))
|
|
1547
|
+
})) };
|
|
1548
|
+
});
|
|
1549
|
+
}
|
|
1550
|
+
function materialsColumns(row) {
|
|
1551
|
+
const aliases2 = { guid: ["IFCGUID", "GLOBALID"], material: ["MATERIAL", "MATERIALNAME", "NOMBREDEMATERIAL"], wbs: ["WBS", "MATERIALWBS", "WBSCOMPLETO"], elementId: ["ELEMENTID", "REVITELEMENTID"], table: ["TABLA", "TABLE"], declaredStatus: ["ESTADO", "STATUS"], rule: ["REGLA/MOTIVO", "REGLA", "RULE"], recordKind: ["TIPODEREGISTRO", "RECORDKIND"], quantityRole: ["USOCANTIDAD", "QUANTITYROLE"], ML: ["ML"], M2: ["M2", "M\xB2"], M3: ["M3", "M\xB3"], PZA: ["PZA"] };
|
|
1552
|
+
const columns = {}, duplicates = [];
|
|
1553
|
+
for (const [field, names2] of Object.entries(aliases2)) {
|
|
1554
|
+
const found = Object.entries(row.cells).filter(([, cell]) => names2.includes(headerKey(cell.value)));
|
|
1555
|
+
if (found.length > 1) duplicates.push(field);
|
|
1556
|
+
if (found.length) columns[field] = found[0][0];
|
|
1557
|
+
}
|
|
1558
|
+
return columns.guid && (columns.material || columns.wbs) ? { columns, duplicates } : null;
|
|
1559
|
+
}
|
|
1560
|
+
function exportedQuantity(value2, unit) {
|
|
1561
|
+
if (typeof value2 === "number") return Number.isFinite(value2) && value2 >= 0 ? value2 : null;
|
|
1562
|
+
if (typeof value2 !== "string") return null;
|
|
1563
|
+
const match = value2.trim().match(/^(\d+(?:\.\d+)?)(?:\s*(m²|m³|m2|m3|ml|m|pza))?$/i);
|
|
1564
|
+
if (!match) return null;
|
|
1565
|
+
const suffix = match[2]?.toUpperCase().replace("\xB2", "2").replace("\xB3", "3");
|
|
1566
|
+
if (suffix && (suffix === "M" ? "ML" : suffix) !== unit) return null;
|
|
1567
|
+
const number = Number(match[1]);
|
|
1568
|
+
return Number.isFinite(number) ? number : null;
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
// src/processing/houses/materials-supplement.mjs
|
|
1572
|
+
var empty = (cell) => cell.value == null || cell.value === "";
|
|
1573
|
+
var normalizedUnit = (unit) => String(unit).trim().toUpperCase().replace("\xB2", "2").replace("\xB3", "3");
|
|
1574
|
+
var textValue = (cell) => typeof cell?.value === "string" ? cell.value.trim() : "";
|
|
1575
|
+
var add2 = (map, key, value2) => map.set(key, [...map.get(key) ?? [], value2]);
|
|
1576
|
+
function inventory(sheets, requested) {
|
|
1577
|
+
const recognized = sheets.flatMap((sheet) => {
|
|
1578
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1579
|
+
for (const row of sheet.rows) counts.set(row.row, (counts.get(row.row) ?? 0) + 1);
|
|
1580
|
+
const header = sheet.rows.find((row) => counts.get(row.row) === 1 && materialsColumns(row));
|
|
1581
|
+
return header ? [{ name: sheet.sheet, rows: sheet.rows.filter((row) => row.row > header.row && !materialsColumns(row) && Object.values(row.cells).some((cell) => !empty(cell))).length }] : [];
|
|
1582
|
+
});
|
|
1583
|
+
if (requested && !recognized.some((sheet) => sheet.name === requested)) throw new Error("La hoja de materiales seleccionada no contiene encabezados reconocidos.");
|
|
1584
|
+
const main2 = recognized.filter((sheet) => headerKey(sheet.name) === "MATERIALES");
|
|
1585
|
+
const selectedSheet = requested || (main2.length === 1 ? main2[0].name : recognized.length === 1 ? recognized[0].name : void 0);
|
|
1586
|
+
return { sheets: recognized, ...selectedSheet ? { selectedSheet } : {}, selectionRequired: recognized.length > 1 && !selectedSheet };
|
|
1587
|
+
}
|
|
1588
|
+
function linkMaterialsWorkbook({ materials, elements = [], concepts = [], associations = /* @__PURE__ */ new Map(), modelAvailable = true, materialsSheet }) {
|
|
1589
|
+
const sheets = readMaterialsSheets(materials.bytes), selection = inventory(sheets, materialsSheet);
|
|
1590
|
+
const source = { name: materials.name, sha256: hash(materials.bytes) };
|
|
1591
|
+
const hosts = new Map(elements.map((element) => [element.id, element])), applications = /* @__PURE__ */ new Map(), elementBindings = /* @__PURE__ */ new Map(), records = [];
|
|
1592
|
+
for (const sheet of sheets) {
|
|
1593
|
+
const rowCounts = /* @__PURE__ */ new Map();
|
|
1594
|
+
for (const row of sheet.rows) rowCounts.set(row.row, (rowCounts.get(row.row) ?? 0) + 1);
|
|
1595
|
+
const header = sheet.rows.filter((row) => rowCounts.get(row.row) === 1).map((row) => ({ row, match: materialsColumns(row) })).find((item) => item.match);
|
|
1596
|
+
for (const row of sheet.rows) {
|
|
1597
|
+
if (Object.values(row.cells).every((cell) => empty(cell) && cell.formula === null && !cell.error)) continue;
|
|
1598
|
+
const record = { sheet: sheet.sheet, row: row.row, values: row.cells, status: "ignored", issues: [] };
|
|
1599
|
+
records.push(record);
|
|
1600
|
+
if (!header) {
|
|
1601
|
+
record.reason = "non-material-table";
|
|
1602
|
+
continue;
|
|
1603
|
+
}
|
|
1604
|
+
if (sheet.sheet !== selection.selectedSheet) {
|
|
1605
|
+
record.reason = selection.selectionRequired ? "sheet-selection-required" : "auxiliary-sheet";
|
|
1606
|
+
continue;
|
|
1607
|
+
}
|
|
1608
|
+
if (rowCounts.get(row.row) > 1) {
|
|
1609
|
+
record.status = "exception";
|
|
1610
|
+
record.issues.push("duplicate-row");
|
|
1611
|
+
continue;
|
|
1612
|
+
}
|
|
1613
|
+
if (row.row <= header.row.row || materialsColumns(row)) {
|
|
1614
|
+
record.reason = "header-or-preamble";
|
|
1615
|
+
continue;
|
|
1616
|
+
}
|
|
1617
|
+
const { columns, duplicates } = header.match, get = (field) => row.cells[columns[field]];
|
|
1618
|
+
const material = textValue(get("material")), guid = textValue(get("guid"));
|
|
1619
|
+
if (!material && !guid) {
|
|
1620
|
+
record.reason = "total-or-empty-material";
|
|
1621
|
+
continue;
|
|
1622
|
+
}
|
|
1623
|
+
record.status = "exception";
|
|
1624
|
+
if (duplicates.length) {
|
|
1625
|
+
record.issues.push("duplicate-columns");
|
|
1626
|
+
continue;
|
|
1627
|
+
}
|
|
1628
|
+
if (!Number.isInteger(row.row) || row.row < 1) {
|
|
1629
|
+
record.issues.push("invalid-row");
|
|
1630
|
+
continue;
|
|
1631
|
+
}
|
|
1632
|
+
if (Object.values(row.cells).some((cell) => cell.formula !== null)) {
|
|
1633
|
+
record.issues.push("formula");
|
|
1634
|
+
continue;
|
|
1635
|
+
}
|
|
1636
|
+
if (Object.values(row.cells).some((cell) => cell.error)) {
|
|
1637
|
+
record.issues.push("cell-error");
|
|
1638
|
+
continue;
|
|
1639
|
+
}
|
|
1640
|
+
if (!guid) {
|
|
1641
|
+
record.issues.push("missing-guid");
|
|
1642
|
+
continue;
|
|
1643
|
+
}
|
|
1644
|
+
record.hostId = guid;
|
|
1645
|
+
if (!hosts.has(guid)) {
|
|
1646
|
+
record.issues.push(modelAvailable ? "unknown-host" : "model-required");
|
|
1647
|
+
continue;
|
|
1648
|
+
}
|
|
1649
|
+
const elementId = get("elementId")?.value;
|
|
1650
|
+
if (elementId != null && elementId !== "" && String(elementId).trim() !== String(hosts.get(guid).elementId)) {
|
|
1651
|
+
record.issues.push("element-id-mismatch");
|
|
1652
|
+
continue;
|
|
1653
|
+
}
|
|
1654
|
+
const declaredKind = headerKey(get("recordKind")?.value), recordKind = declaredKind ? { MATERIAL: "material", ELEMENTO: "element", ELEMENT: "element" }[declaredKind] : material ? "material" : "element";
|
|
1655
|
+
if (!recordKind || recordKind === "material" && !material || recordKind === "element" && material) {
|
|
1656
|
+
record.issues.push("invalid-record-kind");
|
|
1657
|
+
continue;
|
|
1658
|
+
}
|
|
1659
|
+
record.recordKind = recordKind;
|
|
1660
|
+
const wbs = textValue(get("wbs")) || null;
|
|
1661
|
+
if (get("wbs")?.value != null && get("wbs").value !== "" && !wbs) record.issues.push("invalid-wbs");
|
|
1662
|
+
const matches = wbs ? concepts.filter((concept) => concept.code === wbs || concept.qualifiedCode === wbs) : [];
|
|
1663
|
+
const matchStatus = !wbs ? "no-code" : matches.length === 1 ? "unique" : matches.length ? "ambiguous" : "unmatched";
|
|
1664
|
+
if (matchStatus !== "unique") record.issues.push(`wbs-${matchStatus}`);
|
|
1665
|
+
const quantities = [];
|
|
1666
|
+
for (const unit of ["ML", "M2", "M3", "PZA"]) {
|
|
1667
|
+
const cell = get(unit);
|
|
1668
|
+
if (!cell || empty(cell)) continue;
|
|
1669
|
+
const value2 = exportedQuantity(cell.value, unit);
|
|
1670
|
+
if (value2 === null) record.issues.push(`invalid-quantity-${unit}`);
|
|
1671
|
+
else quantities.push({ column: columns[unit], value: value2, unit });
|
|
1672
|
+
}
|
|
1673
|
+
const declaredRole = headerKey(get("quantityRole")?.value);
|
|
1674
|
+
const quantityRole = declaredRole ? { EXPORTED: "exported", EXPORTADA: "exported", CUANTIFICABLE: "exported", DOCUMENTARY: "documentary", DOCUMENTAL: "documentary", UNRESOLVED: "unresolved", PENDIENTE: "unresolved" }[declaredRole] : matches.length === 1 && normalizedUnit(matches[0].unit) === "LOTE" ? "unresolved" : "exported";
|
|
1675
|
+
if (!quantityRole) record.issues.push("invalid-quantity-role");
|
|
1676
|
+
const quantity = quantityRole === "exported" && matches.length === 1 ? quantities.find((item) => item.unit === normalizedUnit(matches[0].unit)) ?? null : null;
|
|
1677
|
+
if (matches.length === 1 && !quantity) record.issues.push("quantity-unavailable");
|
|
1678
|
+
const evidence = {
|
|
1679
|
+
kind: "workbook",
|
|
1680
|
+
...source,
|
|
1681
|
+
sheet: sheet.sheet,
|
|
1682
|
+
row: row.row,
|
|
1683
|
+
...textValue(get("rule")) ? { rule: textValue(get("rule")) } : {},
|
|
1684
|
+
...textValue(get("declaredStatus")) ? { declaredStatus: textValue(get("declaredStatus")) } : {},
|
|
1685
|
+
...textValue(get("table")) ? { table: textValue(get("table")) } : {}
|
|
1686
|
+
};
|
|
1687
|
+
const entry = {
|
|
1688
|
+
id: hash(`${source.sha256}:${sheet.sheet}:${row.row}`),
|
|
1689
|
+
name: material,
|
|
1690
|
+
wbs,
|
|
1691
|
+
candidates: matches.map(({ id, qualifiedCode, unit }) => ({ id, qualifiedCode, unit })),
|
|
1692
|
+
matchStatus,
|
|
1693
|
+
relationStatus: matchStatus === "unique" ? "linked" : "pending",
|
|
1694
|
+
recordKind,
|
|
1695
|
+
quantityRole: quantityRole ?? "unresolved",
|
|
1696
|
+
quantity,
|
|
1697
|
+
quantities,
|
|
1698
|
+
source: { ...source, sheet: sheet.sheet, row: row.row },
|
|
1699
|
+
evidence,
|
|
1700
|
+
issues: record.issues
|
|
1701
|
+
};
|
|
1702
|
+
if (recordKind === "material") {
|
|
1703
|
+
const layers = (associations.get(guid) ?? []).filter((item) => item.name === material).map(({ name: _name, ...reference }) => reference);
|
|
1704
|
+
if (!layers.length) record.issues.push("excel-only-material");
|
|
1705
|
+
entry.materialEvidence = { status: layers.length ? "ifc-layer-name-match" : "excel-only", layers };
|
|
1706
|
+
add2(applications, guid, entry);
|
|
1707
|
+
} else add2(elementBindings, guid, entry);
|
|
1708
|
+
record.status = "linked";
|
|
1709
|
+
record.applicationId = entry.id;
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
for (const apps of applications.values()) {
|
|
1713
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1714
|
+
for (const app of apps) add2(groups, `${app.name}\0${app.wbs}`, app);
|
|
1715
|
+
for (const group of groups.values()) if (group.length > 1) {
|
|
1716
|
+
const signatures = new Set(group.map((app) => JSON.stringify(app.quantities.map(({ value: value2, unit }) => [unit, value2]))));
|
|
1717
|
+
for (const app of group) {
|
|
1718
|
+
app.issues.push("duplicate-host-material");
|
|
1719
|
+
if (signatures.size > 1) {
|
|
1720
|
+
app.quantity = null;
|
|
1721
|
+
app.quantityRole = "unresolved";
|
|
1722
|
+
app.issues.push("conflicting-host-material");
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
const linked = records.filter((record) => record.status === "linked"), exceptions = records.filter((record) => record.issues.length);
|
|
1728
|
+
const summary = {
|
|
1729
|
+
...selection,
|
|
1730
|
+
status: !selection.sheets.length || selection.selectionRequired ? "stored-not-linked" : exceptions.length ? "processed-with-exceptions" : "processed",
|
|
1731
|
+
rows: records.filter((record) => record.status !== "ignored").length,
|
|
1732
|
+
linkedRows: linked.length,
|
|
1733
|
+
applications: [...applications.values()].reduce((count, apps) => count + apps.length, 0),
|
|
1734
|
+
elementRows: [...elementBindings.values()].reduce((count, rows) => count + rows.length, 0),
|
|
1735
|
+
exceptions: exceptions.length,
|
|
1736
|
+
ignoredRows: records.filter((record) => record.status === "ignored").length
|
|
1737
|
+
};
|
|
1738
|
+
return { applications, elementBindings, audit: { source, summary, records }, summary };
|
|
1739
|
+
}
|
|
1740
|
+
function materialsWarnings(summary) {
|
|
1741
|
+
if (summary.selectionRequired) return ["Selecciona la hoja de datos del Excel; las hojas auxiliares no deben duplicar registros."];
|
|
1742
|
+
if (summary.status === "stored-not-linked") return ["Excel guardado: no se identificaron tablas de materiales o elementos con encabezados compatibles."];
|
|
1743
|
+
return [`Excel complementario: ${summary.linkedRows} filas vinculadas por GlobalId; ${summary.exceptions} filas con excepciones. Las cantidades exportadas no se reparten entre capas ni se suman autom\xE1ticamente.`];
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
// src/processing/houses/material-associations.mjs
|
|
1747
|
+
import { createRequire as createRequire2 } from "node:module";
|
|
1748
|
+
var require3 = createRequire2(import.meta.url);
|
|
1749
|
+
var W2 = require3("web-ifc");
|
|
1750
|
+
async function readMaterialAssociations(bytes) {
|
|
1751
|
+
const api = new W2.IfcAPI();
|
|
1752
|
+
await api.Init();
|
|
1753
|
+
const model = api.OpenModel(bytes);
|
|
1754
|
+
if (model < 0) throw new Error("IFC could not be opened");
|
|
1755
|
+
const lines = (type) => {
|
|
1756
|
+
const ids = api.GetLineIDsWithType(model, type, true);
|
|
1757
|
+
return Array.from({ length: ids.size() }, (_, index) => api.GetLine(model, ids.get(index)));
|
|
1758
|
+
};
|
|
1759
|
+
try {
|
|
1760
|
+
const byHost = /* @__PURE__ */ new Map(), types = /* @__PURE__ */ new Map();
|
|
1761
|
+
for (const rel of lines(W2.IFCRELASSOCIATESMATERIAL)) for (const host of rel.RelatedObjects ?? []) byHost.set(host.value, [...byHost.get(host.value) ?? [], rel]);
|
|
1762
|
+
for (const rel of lines(W2.IFCRELDEFINESBYTYPE)) for (const host of rel.RelatedObjects ?? []) types.set(host.value, [...types.get(host.value) ?? [], rel.RelatingType.value]);
|
|
1763
|
+
const resolve2 = (id, seen = /* @__PURE__ */ new Set()) => {
|
|
1764
|
+
if (seen.has(id)) return [];
|
|
1765
|
+
const next = /* @__PURE__ */ new Set([...seen, id]), item = api.GetLine(model, id);
|
|
1766
|
+
if (item.type === W2.IFCMATERIAL) return [{ name: item.Name?.value ?? "", materialExpressId: id }];
|
|
1767
|
+
if (item.ForLayerSet) return resolve2(item.ForLayerSet.value, next);
|
|
1768
|
+
if (item.MaterialLayers) return item.MaterialLayers.flatMap((layer) => resolve2(layer.value, next));
|
|
1769
|
+
if (item.Material) return resolve2(item.Material.value, next).map((material) => ({ ...material, ...item.type === W2.IFCMATERIALLAYER ? { layerExpressId: id } : {} }));
|
|
1770
|
+
if (item.Materials) return item.Materials.flatMap((material) => resolve2(material.value, next));
|
|
1771
|
+
return [];
|
|
1772
|
+
};
|
|
1773
|
+
return new Map(lines(W2.IFCELEMENT).map((element) => {
|
|
1774
|
+
const direct = byHost.get(element.expressID) ?? [];
|
|
1775
|
+
const inherited = (types.get(element.expressID) ?? []).flatMap((type) => byHost.get(type) ?? []);
|
|
1776
|
+
const associations = direct.length ? direct : inherited;
|
|
1777
|
+
return [element.GlobalId?.value, associations.flatMap((rel) => resolve2(rel.RelatingMaterial.value).map((material) => ({ ...material, associationExpressId: rel.expressID, origin: direct.length ? "instance" : "type" })))];
|
|
1778
|
+
}));
|
|
1779
|
+
} finally {
|
|
1780
|
+
api.CloseModel(model);
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
// src/processing/houses/pipeline.mjs
|
|
1785
|
+
var jsonBytes = (value2) => new TextEncoder().encode(JSON.stringify(value2, null, 2) + "\n");
|
|
1786
|
+
var safeId = (value2) => typeof value2 === "string" && /^[a-z0-9][a-z0-9_-]{0,79}$/.test(value2);
|
|
1787
|
+
var publicCatalog = (catalog) => ({ ...catalog, roots: catalog.roots.length });
|
|
1788
|
+
function modelRevision(bytes) {
|
|
1789
|
+
const text = new TextDecoder().decode(bytes), header = text.slice(0, text.indexOf("ENDSEC;"));
|
|
1790
|
+
const revision = Object.fromEntries([["contentId", "ContentGUID"], ["versionId", "VersionGUID"]].map(([key, marker]) => {
|
|
1791
|
+
const matches = [...header.matchAll(new RegExp(`${marker}: ([a-fA-F0-9-]+)`, "g"))];
|
|
1792
|
+
if (matches.length !== 1 || !/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(matches[0][1])) throw new Error(`El IFC no declara ${marker} \xFAnico de Revit; entregar una exportaci\xF3n coordinada.`);
|
|
1793
|
+
return [key, matches[0][1]];
|
|
1794
|
+
}));
|
|
1795
|
+
verifyMetadata(text, revision, "ifc");
|
|
1796
|
+
return revision;
|
|
1797
|
+
}
|
|
1798
|
+
function validatePdfs(pdfs) {
|
|
1799
|
+
if (!Array.isArray(pdfs) || new Set(pdfs.map((pdf) => pdf.id)).size !== pdfs.length || pdfs.some((pdf) => !safeId(pdf.id) || ["model", "catalog"].includes(pdf.id) || typeof pdf.name !== "string" || !pdf.name.trim() || !(pdf.bytes instanceof Uint8Array))) throw new Error("Cada PDF requiere ID \xFAnico seguro, nombre y contenido (model/catalog son reservados).");
|
|
1800
|
+
}
|
|
1801
|
+
async function inspectDelivery({ catalogBytes, modelBytes, pdfs = [], materials }) {
|
|
1802
|
+
validatePdfs(pdfs);
|
|
1803
|
+
const catalog = readHouseCatalog(catalogBytes), warnings = [...catalog.warnings];
|
|
1804
|
+
const pdfInventory = await Promise.all(pdfs.map(async (pdf) => ({ id: pdf.id, name: pdf.name, pages: (await PDFDocument3.load(pdf.bytes)).getPageCount() })));
|
|
1805
|
+
let model = null, rawElements = [];
|
|
1806
|
+
if (modelBytes) {
|
|
1807
|
+
const revision = modelRevision(modelBytes), elements = await readModel(modelBytes);
|
|
1808
|
+
rawElements = elements;
|
|
1809
|
+
model = { revision, levels: [...new Set(elements.map((element) => element.level).filter(Boolean))].toSorted(), elementCount: elements.length };
|
|
1810
|
+
for (const pdf of pdfs) {
|
|
1811
|
+
try {
|
|
1812
|
+
await verifyPdfMetadata(pdf.bytes, revision);
|
|
1813
|
+
} catch {
|
|
1814
|
+
warnings.push(`${pdf.name}: no coincide con la revisi\xF3n IFC o no tiene metadatos coordinados; no podr\xE1 procesarse como vista interactiva.`);
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
} else warnings.push("Falta IFC coordinado: el cat\xE1logo puede revisarse, pero a\xFAn no genera un visor interactivo.");
|
|
1818
|
+
if (!pdfs.length) warnings.push("Faltan PDF vectoriales identificados para crear vistas interactivas.");
|
|
1819
|
+
const materialsReview = materials ? linkMaterialsWorkbook({ materials, elements: rawElements, concepts: catalog.concepts, modelAvailable: Boolean(modelBytes), associations: modelBytes ? await readMaterialAssociations(modelBytes) : /* @__PURE__ */ new Map() }) : null;
|
|
1820
|
+
if (materialsReview) {
|
|
1821
|
+
warnings.push(...materialsWarnings(materialsReview.summary));
|
|
1822
|
+
if (!modelBytes) warnings.push("Adjunta el IFC para comprobar los GlobalId de las filas de materiales; a\xFAn no hay piezas con las cuales enlazarlas.");
|
|
1823
|
+
}
|
|
1824
|
+
return { ...materialsReview ? { materials: materialsReview.summary } : {}, catalog: publicCatalog(catalog), ...model ? { model } : {}, pdfs: pdfInventory, warnings };
|
|
1825
|
+
}
|
|
1826
|
+
function validateHouseConfig(config, catalog, pdfs) {
|
|
1827
|
+
if (!config || !Array.isArray(config.floors) || !config.floors.length || config.floors.some((floor) => !safeId(floor.id) || typeof floor.label !== "string" || !floor.label.trim()) || new Set(config.floors.map((floor) => floor.id)).size !== config.floors.length) throw new Error("Define plantas con ID \xFAnico y nombre.");
|
|
1828
|
+
const floors = new Set(config.floors.map((floor) => floor.id)), sources = new Set(pdfs.map((pdf) => pdf.id)), seenViews = /* @__PURE__ */ new Set();
|
|
1829
|
+
if (!Array.isArray(config.views) || !config.views.length) throw new Error("Configura al menos una vista PDF.");
|
|
1830
|
+
for (const view of config.views) {
|
|
1831
|
+
if (!sources.has(view.pdfId) || !Number.isInteger(view.page) || view.page < 1 || !floors.has(view.floorId) || typeof view.title !== "string" || !view.title.trim()) throw new Error("Cada vista requiere PDF, p\xE1gina positiva, planta y t\xEDtulo.");
|
|
1832
|
+
const key = `${view.pdfId}:${view.page}`;
|
|
1833
|
+
if (seenViews.has(key)) throw new Error("La misma p\xE1gina PDF no puede configurarse dos veces.");
|
|
1834
|
+
seenViews.add(key);
|
|
1835
|
+
}
|
|
1836
|
+
if (!Array.isArray(config.scopeCodes) || config.scopeCodes.some((code) => !catalog.concepts.some((concept) => concept.qualifiedCode === code)) || new Set(config.scopeCodes).size !== config.scopeCodes.length) throw new Error("El alcance debe usar c\xF3digos WBS completos y \xFAnicos del cat\xE1logo.");
|
|
1837
|
+
if (!["MXN", "unconfirmed"].includes(config.currency)) throw new Error("Indica MXN confirmado o moneda sin confirmar.");
|
|
1838
|
+
if (!config.floorBindings || typeof config.floorBindings !== "object" || Array.isArray(config.floorBindings)) throw new Error("Falta la asociaci\xF3n expl\xEDcita WBS\u2013plantas.");
|
|
1839
|
+
for (const [code, ids] of Object.entries(config.floorBindings)) {
|
|
1840
|
+
if (!catalog.nodes.some((node) => node.code === code) || !Array.isArray(ids) || !ids.length || ids.some((id) => !floors.has(id)) || new Set(ids).size !== ids.length) throw new Error(`Asociaci\xF3n WBS\u2013plantas inv\xE1lida: ${code}`);
|
|
1841
|
+
}
|
|
1842
|
+
const included = catalog.concepts.filter((concept) => !config.scopeCodes.length || config.scopeCodes.includes(concept.qualifiedCode));
|
|
1843
|
+
const requiredRoots = catalog.roots.filter((root) => included.some((concept) => concept.chapterCode === root.code || concept.chapterCode.startsWith(`${root.code}.`)));
|
|
1844
|
+
for (const root of requiredRoots) if (!Object.hasOwn(config.floorBindings, root.code)) throw new Error(`Asigna expl\xEDcitamente plantas a la partida ${root.code} (${root.label}).`);
|
|
1845
|
+
return config;
|
|
1846
|
+
}
|
|
1847
|
+
function hierarchyFor(catalog, config) {
|
|
1848
|
+
const byCode = new Map(catalog.nodes.map((node) => [node.code, node]));
|
|
1849
|
+
const bindingFor = (code) => {
|
|
1850
|
+
let current = code;
|
|
1851
|
+
while (current) {
|
|
1852
|
+
if (Object.hasOwn(config.floorBindings, current)) return config.floorBindings[current];
|
|
1853
|
+
current = byCode.get(current)?.parentCode;
|
|
1854
|
+
}
|
|
1855
|
+
return [];
|
|
1856
|
+
};
|
|
1857
|
+
const rootFor = (code) => {
|
|
1858
|
+
let node = byCode.get(code);
|
|
1859
|
+
while (node.parentCode) node = byCode.get(node.parentCode);
|
|
1860
|
+
return node.id;
|
|
1861
|
+
};
|
|
1862
|
+
const concepts = catalog.concepts.map((concept) => ({ ...concept, floorIds: [...bindingFor(concept.chapterCode)], wbsNodeId: rootFor(concept.chapterCode) }));
|
|
1863
|
+
const nodes = catalog.nodes.map((node) => {
|
|
1864
|
+
const assigned = new Set(catalog.nodes.filter((item) => item.code === node.code || item.code.startsWith(`${node.code}.`)).flatMap((item) => bindingFor(item.code)));
|
|
1865
|
+
return { id: node.id, code: node.code, label: node.label, parentId: node.parentId, floorIds: config.floors.filter((floor) => assigned.has(floor.id)).map((floor) => floor.id) };
|
|
1866
|
+
});
|
|
1867
|
+
return { concepts, nodes };
|
|
1868
|
+
}
|
|
1869
|
+
function publishConcept(concept, currency) {
|
|
1870
|
+
const { id, code, qualifiedCode, description, chapter, unit, quantity, parentId, wbsNodeId, floorIds } = concept;
|
|
1871
|
+
const budget = currency === "MXN" ? { unitPrice: concept.unitPrice, currency: "MXN", status: "pending", note: "Precio exportado del cat\xE1logo en MXN. Tratamiento de impuestos pendiente de confirmar." } : null;
|
|
1872
|
+
return { id, code, qualifiedCode, description, shortName: conceptName(description), chapter, unit, quantity, parentId, wbsNodeId, floorIds, budget };
|
|
1873
|
+
}
|
|
1874
|
+
function publishElements(elements, enabled) {
|
|
1875
|
+
return elements.map((element) => ({
|
|
1876
|
+
id: element.id,
|
|
1877
|
+
elementId: element.elementId,
|
|
1878
|
+
name: element.name,
|
|
1879
|
+
category: element.category,
|
|
1880
|
+
categoryLabel: element.categoryLabel,
|
|
1881
|
+
level: element.level,
|
|
1882
|
+
family: element.family,
|
|
1883
|
+
typeName: element.typeName,
|
|
1884
|
+
displayName: element.displayName,
|
|
1885
|
+
wbs: element.wbs,
|
|
1886
|
+
namesByConcept: Object.fromEntries(Object.entries(element.namesByConcept ?? {}).filter(([id]) => enabled.has(id))),
|
|
1887
|
+
quantitiesByConcept: Object.fromEntries(Object.entries(element.quantitiesByConcept ?? {}).filter(([id]) => enabled.has(id))),
|
|
1888
|
+
...element.materialApplications ? { materialApplications: element.materialApplications } : {},
|
|
1889
|
+
relations: element.relations.filter((relation) => enabled.has(relation.conceptId)),
|
|
1890
|
+
measurements: compactMeasurements(element),
|
|
1891
|
+
dimensions: element.dimensions
|
|
1892
|
+
}));
|
|
1893
|
+
}
|
|
1894
|
+
async function processDelivery({ houseId, revisionId, name, catalogBytes, modelBytes, pdfs = [], config, materials, assetUrl }) {
|
|
1895
|
+
if (!safeId(houseId) || !safeId(revisionId) || typeof name !== "string" || !name.trim()) throw new Error("Casa y revisi\xF3n requieren ID seguro y un nombre.");
|
|
1896
|
+
if (!(modelBytes instanceof Uint8Array)) throw new Error("Falta el IFC coordinado para procesar.");
|
|
1897
|
+
validatePdfs(pdfs);
|
|
1898
|
+
const catalog = readHouseCatalog(catalogBytes);
|
|
1899
|
+
validateHouseConfig(config, catalog, pdfs);
|
|
1900
|
+
const revision = modelRevision(modelBytes), modelSource = { id: "model", sha256: hash(modelBytes) }, catalogSource = { id: "catalog", sha256: hash(catalogBytes) };
|
|
1901
|
+
const hierarchy = hierarchyFor(catalog, config);
|
|
1902
|
+
const selected = hierarchy.concepts.filter((concept) => !config.scopeCodes.length || config.scopeCodes.includes(concept.qualifiedCode)), enabled = new Set(selected.map((concept) => concept.id));
|
|
1903
|
+
const rawElements = await readModel(modelBytes);
|
|
1904
|
+
const materialsReview = materials ? linkMaterialsWorkbook({ materials, elements: rawElements, concepts: hierarchy.concepts, associations: await readMaterialAssociations(modelBytes), materialsSheet: config.materialsSheet }) : null;
|
|
1905
|
+
if (materialsReview?.summary.selectionRequired) throw new Error("Selecciona una hoja de datos del Excel complementario antes de generar el visor.");
|
|
1906
|
+
const enriched = linkDeliveryRelations(rawElements, hierarchy.concepts, materialsReview, modelSource.sha256);
|
|
1907
|
+
if (materialsReview) {
|
|
1908
|
+
materialsReview.summary.exceptions = materialsReview.audit.records.filter((record) => record.issues.length).length;
|
|
1909
|
+
if (materialsReview.summary.exceptions) materialsReview.summary.status = "processed-with-exceptions";
|
|
1910
|
+
}
|
|
1911
|
+
const assets = /* @__PURE__ */ new Map(), views = [], geometryAudit = [], rejectedViews = [], warnings = [...catalog.warnings], sourceEntries = [catalogSource, modelSource];
|
|
1912
|
+
if (materialsReview) {
|
|
1913
|
+
const { bytes: _bytes, ...materialSource } = materials;
|
|
1914
|
+
sourceEntries.push({ ...materialSource, sha256: materialsReview.audit.source.sha256, status: materialsReview.summary.status });
|
|
1915
|
+
warnings.push(...materialsWarnings(materialsReview.summary));
|
|
1916
|
+
}
|
|
1917
|
+
const url = `/data/houses/${houseId}/${revisionId}`;
|
|
1918
|
+
const resolveAssetUrl = typeof assetUrl === "function" ? assetUrl : (file) => `${url}/${file}`;
|
|
1919
|
+
const sources = new Map(pdfs.map((pdf) => [pdf.id, pdf]));
|
|
1920
|
+
sourceEntries.push(...pdfs.map((pdf) => ({ id: pdf.id, name: pdf.name, sha256: hash(pdf.bytes) })));
|
|
1921
|
+
const rejectView = (view, reason) => {
|
|
1922
|
+
rejectedViews.push({ ...view, reason });
|
|
1923
|
+
warnings.push(reason);
|
|
1924
|
+
};
|
|
1925
|
+
for (const view of config.views) {
|
|
1926
|
+
const pdf = sources.get(view.pdfId), source = { id: pdf.id, name: pdf.name, sha256: hash(pdf.bytes) };
|
|
1927
|
+
try {
|
|
1928
|
+
await verifyPdfMetadata(pdf.bytes, revision);
|
|
1929
|
+
} catch {
|
|
1930
|
+
rejectView(view, `${pdf.name}: PDF e IFC no comparten ContentGUID/VersionGUID. Reexportar ambos desde la misma revisi\xF3n.`);
|
|
1931
|
+
continue;
|
|
1932
|
+
}
|
|
1933
|
+
const viewId = `${pdf.id}-p${view.page}`;
|
|
1934
|
+
const parsedDrawing = await readDocumentGeometry(pdf.bytes, { pageNumber: view.page });
|
|
1935
|
+
if (!parsedDrawing.supported || !parsedDrawing.viewBox) {
|
|
1936
|
+
rejectView(view, `${pdf.name}, p\xE1gina ${view.page}: vista vectorial no admitida. ${parsedDrawing.diagnostics.join("; ")}`);
|
|
1937
|
+
continue;
|
|
1938
|
+
}
|
|
1939
|
+
const drawing = requireSupportedDrawing(parsedDrawing, viewId);
|
|
1940
|
+
const linked = linkRepresentations({ drawing, elements: enriched, viewId, source, modelSource, page: view.page });
|
|
1941
|
+
try {
|
|
1942
|
+
requireInteractiveView({ drawing, geometry: linked.geometry, viewId });
|
|
1943
|
+
} catch {
|
|
1944
|
+
rejectView(view, `${pdf.name}, p\xE1gina ${view.page}: faltan contornos vectoriales identificados que coincidan con el IFC. Exportar la vista de Revit en modo vectorial coordinado.`);
|
|
1945
|
+
continue;
|
|
1946
|
+
}
|
|
1947
|
+
const geometryBytes = jsonBytes(linked.geometry), geometryFile = `${viewId}.json`, pdfFile = `${pdf.id}.pdf`;
|
|
1948
|
+
assets.set(geometryFile, geometryBytes);
|
|
1949
|
+
assets.set(pdfFile, pdf.bytes);
|
|
1950
|
+
views.push({
|
|
1951
|
+
id: viewId,
|
|
1952
|
+
floorId: view.floorId,
|
|
1953
|
+
title: view.title,
|
|
1954
|
+
page: view.page,
|
|
1955
|
+
pdfUrl: resolveAssetUrl(pdfFile),
|
|
1956
|
+
geometryUrl: resolveAssetUrl(geometryFile),
|
|
1957
|
+
pdfSha256: source.sha256,
|
|
1958
|
+
modelSha256: modelSource.sha256,
|
|
1959
|
+
geometrySha256: hash(geometryBytes),
|
|
1960
|
+
width: linked.geometry.viewBox[2],
|
|
1961
|
+
height: linked.geometry.viewBox[3],
|
|
1962
|
+
elementIds: linked.geometry.pieces.map((piece) => piece.id)
|
|
1963
|
+
});
|
|
1964
|
+
geometryAudit.push({ viewId, source, page: view.page, ...linked.audit });
|
|
1965
|
+
if (linked.audit.unknownOwners.length) warnings.push(`${view.title}: ${linked.audit.unknownOwners.length} identificadores del PDF no existen en el IFC; no se invent\xF3 geometr\xEDa interactiva para ellos.`);
|
|
1966
|
+
if (!sourceEntries.some((entry) => entry.id === source.id)) sourceEntries.push(source);
|
|
1967
|
+
}
|
|
1968
|
+
if (!views.length) throw new Error(`No se pudo generar ninguna vista interactiva. ${rejectedViews.map((view) => view.reason).join(" ")}`);
|
|
1969
|
+
const project = {
|
|
1970
|
+
schemaVersion: 1,
|
|
1971
|
+
id: houseId,
|
|
1972
|
+
name: name.trim(),
|
|
1973
|
+
revision: revision.versionId,
|
|
1974
|
+
defaultViewId: views[0].id,
|
|
1975
|
+
floors: config.floors,
|
|
1976
|
+
views,
|
|
1977
|
+
concepts: selected.map((concept) => publishConcept(concept, config.currency)),
|
|
1978
|
+
elements: publishElements(enriched, enabled),
|
|
1979
|
+
wbsNodes: hierarchy.nodes
|
|
1980
|
+
};
|
|
1981
|
+
const reviewCandidates = enriched.flatMap((element) => element.relations.filter((relation) => enabled.has(relation.conceptId)).map((relation) => ({
|
|
1982
|
+
id: hash(`${modelSource.sha256}:${catalogSource.sha256}:${element.id}:${relation.conceptId}`),
|
|
1983
|
+
globalId: element.id,
|
|
1984
|
+
elementId: element.elementId,
|
|
1985
|
+
conceptId: relation.conceptId,
|
|
1986
|
+
qualifiedCode: catalog.concepts.find((concept) => concept.id === relation.conceptId).qualifiedCode,
|
|
1987
|
+
ambiguous: relation.ambiguous,
|
|
1988
|
+
status: relation.status,
|
|
1989
|
+
kind: relation.kind,
|
|
1990
|
+
evidence: relation.evidence,
|
|
1991
|
+
issues: relation.issues,
|
|
1992
|
+
modelSha256: modelSource.sha256,
|
|
1993
|
+
catalogSha256: catalogSource.sha256,
|
|
1994
|
+
viewIds: views.filter((view) => view.elementIds.includes(element.id)).map((view) => view.id)
|
|
1995
|
+
})));
|
|
1996
|
+
const ambiguous = reviewCandidates.filter((candidate) => candidate.ambiguous).length;
|
|
1997
|
+
if (ambiguous) warnings.push(`${ambiguous} relaciones tienen varios candidatos exactos en el cat\xE1logo; requieren resolver la partida, no aprobaci\xF3n autom\xE1tica.`);
|
|
1998
|
+
if (!reviewCandidates.length) warnings.push("No se encontraron relaciones WBS exactas dentro del alcance; el plano no acredita por s\xED solo una asociaci\xF3n con partidas.");
|
|
1999
|
+
const withoutWbs = rawElements.filter((element) => !element.wbs.length).length;
|
|
2000
|
+
if (withoutWbs) warnings.push(`${withoutWbs} elementos IFC no declaran WBS propio; solo se a\xF1aden las relaciones expl\xEDcitas que aporte el Excel complementario.`);
|
|
2001
|
+
if (config.currency === "unconfirmed") warnings.push("Moneda sin confirmar: precios e importes permanecen en auditor\xEDa y no se publican como valores financieros.");
|
|
2002
|
+
const unmatchedCodes = [...new Set(enriched.flatMap((element) => element.wbs.filter((code) => !catalog.concepts.some((concept) => concept.code === code || concept.qualifiedCode === code))))].toSorted();
|
|
2003
|
+
if (unmatchedCodes.length) warnings.push(`${unmatchedCodes.length} c\xF3digos IFC no tienen coincidencia exacta con el cat\xE1logo.`);
|
|
2004
|
+
const audit = {
|
|
2005
|
+
schemaVersion: 1,
|
|
2006
|
+
houseId,
|
|
2007
|
+
revisionId,
|
|
2008
|
+
revision,
|
|
2009
|
+
sources: sourceEntries,
|
|
2010
|
+
config,
|
|
2011
|
+
catalog: publicCatalog(catalog),
|
|
2012
|
+
...materialsReview ? { materials: materialsReview.audit } : {},
|
|
2013
|
+
elements: enriched,
|
|
2014
|
+
views: geometryAudit,
|
|
2015
|
+
rejectedViews,
|
|
2016
|
+
reviewCandidates,
|
|
2017
|
+
unmatchedCodes,
|
|
2018
|
+
warnings,
|
|
2019
|
+
counts: { catalogConcepts: catalog.conceptCount, publishedConcepts: selected.length, rootNodes: catalog.roots.length, modelElements: rawElements.length, views: views.length, representedElements: new Set(views.flatMap((view) => view.elementIds)).size, pendingRelations: reviewCandidates.filter((candidate) => candidate.status === "pending").length, linkedRelations: reviewCandidates.filter((candidate) => candidate.status === "linked").length, ambiguousRelations: ambiguous },
|
|
2020
|
+
semantics: "Relaciones exactas vinculadas autom\xE1ticamente con evidencia de fuente; conflictos pendientes sin aprobaci\xF3n humana atribuida. El alcance no resuelve ambig\xFCedades. Geometr\xEDa vectorial PDF vinculada por ElementId IFC; sin c\xE1lculo de \xE1reas/vol\xFAmenes ni geometr\xEDa inventada. Todas las cantidades IFC permanecen en esta auditor\xEDa."
|
|
2021
|
+
};
|
|
2022
|
+
return { project, assets, audit };
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
// src/version.ts
|
|
2026
|
+
import { readFileSync } from "node:fs";
|
|
2027
|
+
var manifest = JSON.parse(
|
|
2028
|
+
readFileSync(new URL("../package.json", import.meta.url), "utf8")
|
|
2029
|
+
);
|
|
2030
|
+
var pipelineVersion = manifest.version;
|
|
2031
|
+
var validatorVersion = manifest.version;
|
|
2032
|
+
|
|
2033
|
+
// src/contract/derive-config.ts
|
|
2034
|
+
function planoSources(manifest2) {
|
|
2035
|
+
const pdfIds = /* @__PURE__ */ new Map();
|
|
2036
|
+
manifest2.archivos.filter((file) => file.rol === "plano").forEach((plano2, index) => pdfIds.set(plano2, `pdf-${index + 1}`));
|
|
2037
|
+
return { pdfIds };
|
|
2038
|
+
}
|
|
2039
|
+
function floorId(label2) {
|
|
2040
|
+
const slug = label2.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 79);
|
|
2041
|
+
return slug || "planta";
|
|
2042
|
+
}
|
|
2043
|
+
function deriveImportConfig(manifest2, catalog) {
|
|
2044
|
+
const { pdfIds } = planoSources(manifest2);
|
|
2045
|
+
const floors = [];
|
|
2046
|
+
const idsByLabel = /* @__PURE__ */ new Map();
|
|
2047
|
+
for (const plano2 of pdfIds.keys()) {
|
|
2048
|
+
if (idsByLabel.has(plano2.planta)) continue;
|
|
2049
|
+
let id = floorId(plano2.planta);
|
|
2050
|
+
for (let n = 2; floors.some((floor) => floor.id === id); n++) id = `${floorId(plano2.planta)}-${n}`;
|
|
2051
|
+
idsByLabel.set(plano2.planta, id);
|
|
2052
|
+
floors.push({ id, label: plano2.planta });
|
|
2053
|
+
}
|
|
2054
|
+
const views = [...pdfIds.entries()].map(([plano2, pdfId]) => ({
|
|
2055
|
+
pdfId,
|
|
2056
|
+
page: plano2.pagina,
|
|
2057
|
+
title: plano2.titulo ?? plano2.planta,
|
|
2058
|
+
floorId: idsByLabel.get(plano2.planta)
|
|
2059
|
+
}));
|
|
2060
|
+
const allFloorIds = floors.map((floor) => floor.id);
|
|
2061
|
+
const floorBindings = Object.fromEntries(
|
|
2062
|
+
catalog.nodes.filter((node) => node.parentId === null).map((node) => [node.code, allFloorIds])
|
|
2063
|
+
);
|
|
2064
|
+
const materials = manifest2.archivos.find((file) => file.rol === "materiales");
|
|
2065
|
+
return {
|
|
2066
|
+
floors,
|
|
2067
|
+
views,
|
|
2068
|
+
scopeCodes: [],
|
|
2069
|
+
floorBindings,
|
|
2070
|
+
currency: "unconfirmed",
|
|
2071
|
+
...materials?.hoja ? { materialsSheet: materials.hoja } : {}
|
|
2072
|
+
};
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
// src/contract/manifest.ts
|
|
2076
|
+
import { Schema } from "effect";
|
|
2077
|
+
var sha256 = Schema.String.check(Schema.isPattern(/^[a-fA-F0-9]{64}$/));
|
|
2078
|
+
var fileName = Schema.String.check(
|
|
2079
|
+
Schema.isPattern(/^[^/\\\u0000-\u001f]+\.[A-Za-z0-9]{1,16}$/),
|
|
2080
|
+
Schema.isMaxLength(255)
|
|
2081
|
+
);
|
|
2082
|
+
var label = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(120));
|
|
2083
|
+
var sheetName = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(31));
|
|
2084
|
+
var fileBase = { archivo: fileName, sha256 };
|
|
2085
|
+
var catalogo = Schema.Struct({ rol: Schema.Literal("catalogo"), ...fileBase });
|
|
2086
|
+
var modelo = Schema.Struct({ rol: Schema.Literal("modelo"), ...fileBase });
|
|
2087
|
+
var plano = Schema.Struct({
|
|
2088
|
+
rol: Schema.Literal("plano"),
|
|
2089
|
+
...fileBase,
|
|
2090
|
+
planta: label,
|
|
2091
|
+
pagina: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 1e4 })),
|
|
2092
|
+
titulo: Schema.optional(label)
|
|
2093
|
+
});
|
|
2094
|
+
var materiales = Schema.Struct({
|
|
2095
|
+
rol: Schema.Literal("materiales"),
|
|
2096
|
+
...fileBase,
|
|
2097
|
+
hoja: Schema.optional(sheetName)
|
|
2098
|
+
});
|
|
2099
|
+
var manifestFileSchema = Schema.Union([catalogo, modelo, plano, materiales]);
|
|
2100
|
+
var deliveryManifestSchema = Schema.Struct({
|
|
2101
|
+
schemaVersion: Schema.Literal(1),
|
|
2102
|
+
proyecto: label,
|
|
2103
|
+
revision: label,
|
|
2104
|
+
fecha: Schema.String.check(Schema.isPattern(/^\d{4}-\d{2}-\d{2}$/)),
|
|
2105
|
+
notas: Schema.optional(Schema.String.check(Schema.isMaxLength(2e3))),
|
|
2106
|
+
archivos: Schema.Array(manifestFileSchema).check(
|
|
2107
|
+
Schema.isMinLength(2),
|
|
2108
|
+
Schema.isMaxLength(40)
|
|
2109
|
+
)
|
|
2110
|
+
});
|
|
2111
|
+
var decode = Schema.decodeUnknownSync(deliveryManifestSchema);
|
|
2112
|
+
function parseManifest(input) {
|
|
2113
|
+
let manifest2;
|
|
2114
|
+
try {
|
|
2115
|
+
const decoded = decode(input, { onExcessProperty: "error" });
|
|
2116
|
+
manifest2 = {
|
|
2117
|
+
...decoded,
|
|
2118
|
+
archivos: decoded.archivos.map((file) => ({ ...file, sha256: file.sha256.toLowerCase() }))
|
|
2119
|
+
};
|
|
2120
|
+
} catch (error) {
|
|
2121
|
+
const mensaje = Schema.isSchemaError(error) ? error.message : "El manifiesto no es un objeto JSON v\xE1lido.";
|
|
2122
|
+
return {
|
|
2123
|
+
ok: false,
|
|
2124
|
+
findings: [{ regla: "manifest.invalid", nivel: "error", mensaje }]
|
|
2125
|
+
};
|
|
2126
|
+
}
|
|
2127
|
+
const findings = structuralFindings(manifest2);
|
|
2128
|
+
return findings.length ? { ok: false, findings } : { ok: true, manifest: manifest2 };
|
|
2129
|
+
}
|
|
2130
|
+
function structuralFindings(manifest2) {
|
|
2131
|
+
const findings = [];
|
|
2132
|
+
const count = (rol) => manifest2.archivos.filter((file) => file.rol === rol).length;
|
|
2133
|
+
const expectExactly = (rol, expected) => {
|
|
2134
|
+
const actual = count(rol);
|
|
2135
|
+
if (actual !== expected)
|
|
2136
|
+
findings.push({
|
|
2137
|
+
regla: "manifest.role-count",
|
|
2138
|
+
nivel: "error",
|
|
2139
|
+
mensaje: `La entrega requiere exactamente ${expected} archivo con rol "${rol}"; se declararon ${actual}.`
|
|
2140
|
+
});
|
|
2141
|
+
};
|
|
2142
|
+
expectExactly("catalogo", 1);
|
|
2143
|
+
expectExactly("modelo", 1);
|
|
2144
|
+
if (count("plano") === 0)
|
|
2145
|
+
findings.push({
|
|
2146
|
+
regla: "manifest.role-count",
|
|
2147
|
+
nivel: "error",
|
|
2148
|
+
mensaje: 'La entrega requiere al menos un archivo con rol "plano".'
|
|
2149
|
+
});
|
|
2150
|
+
if (count("materiales") > 1)
|
|
2151
|
+
findings.push({
|
|
2152
|
+
regla: "manifest.role-count",
|
|
2153
|
+
nivel: "error",
|
|
2154
|
+
mensaje: `La entrega admite un solo archivo con rol "materiales"; se declararon ${count("materiales")}.`
|
|
2155
|
+
});
|
|
2156
|
+
const seenNames = /* @__PURE__ */ new Set();
|
|
2157
|
+
for (const file of manifest2.archivos) {
|
|
2158
|
+
if (seenNames.has(file.archivo))
|
|
2159
|
+
findings.push({
|
|
2160
|
+
regla: "manifest.duplicate-file",
|
|
2161
|
+
nivel: "error",
|
|
2162
|
+
archivo: file.archivo,
|
|
2163
|
+
mensaje: `El archivo "${file.archivo}" se declara m\xE1s de una vez.`
|
|
2164
|
+
});
|
|
2165
|
+
seenNames.add(file.archivo);
|
|
2166
|
+
}
|
|
2167
|
+
const seenViews = /* @__PURE__ */ new Set();
|
|
2168
|
+
for (const file of manifest2.archivos) {
|
|
2169
|
+
if (file.rol !== "plano") continue;
|
|
2170
|
+
const key = `${file.archivo}#${file.pagina}`;
|
|
2171
|
+
if (seenViews.has(key))
|
|
2172
|
+
findings.push({
|
|
2173
|
+
regla: "manifest.duplicate-view",
|
|
2174
|
+
nivel: "error",
|
|
2175
|
+
archivo: file.archivo,
|
|
2176
|
+
mensaje: `La p\xE1gina ${file.pagina} de "${file.archivo}" se declara como vista m\xE1s de una vez.`
|
|
2177
|
+
});
|
|
2178
|
+
seenViews.add(key);
|
|
2179
|
+
}
|
|
2180
|
+
return findings;
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
// src/contract/policy.ts
|
|
2184
|
+
var policyVersion = "2026-10-20.1";
|
|
2185
|
+
var rules = {
|
|
2186
|
+
// Manifest and integrity
|
|
2187
|
+
"manifest.invalid": { nivel: "error", descripcion: "entrega.json no cumple el schema." },
|
|
2188
|
+
"manifest.role-count": { nivel: "error", descripcion: "N\xFAmero de archivos por rol fuera del contrato." },
|
|
2189
|
+
"manifest.duplicate-file": { nivel: "error", descripcion: "Archivo declarado dos veces." },
|
|
2190
|
+
"manifest.duplicate-view": { nivel: "error", descripcion: "Misma p\xE1gina PDF declarada dos veces." },
|
|
2191
|
+
"delivery.file-missing": { nivel: "error", descripcion: "Archivo declarado que no est\xE1 en la entrega." },
|
|
2192
|
+
"delivery.hash-mismatch": { nivel: "error", descripcion: "El contenido no coincide con la huella declarada." },
|
|
2193
|
+
"delivery.file-undeclared": { nivel: "observacion", descripcion: "Archivo presente pero no declarado; se ignora." },
|
|
2194
|
+
// Catalog
|
|
2195
|
+
"catalog.invalid": { nivel: "error", descripcion: "El cat\xE1logo no cumple la plantilla (hoja, columnas, jerarqu\xEDa, valores)." },
|
|
2196
|
+
// IFC
|
|
2197
|
+
"model.invalid": { nivel: "error", descripcion: "El IFC no es legible o no declara la revisi\xF3n de Revit." },
|
|
2198
|
+
"model.wbs-unmatched": { nivel: "error", descripcion: "Pieza IFC con c\xF3digo WBS que no existe en el cat\xE1logo." },
|
|
2199
|
+
"model.without-wbs": { nivel: "observacion", descripcion: "Piezas IFC sin WBS propio." },
|
|
2200
|
+
// PDF views
|
|
2201
|
+
"view.page-out-of-range": { nivel: "error", descripcion: "P\xE1gina declarada inexistente en el PDF." },
|
|
2202
|
+
"view.rejected": { nivel: "error", descripcion: "Vista no admitida: revisi\xF3n distinta al IFC, no vectorial o sin piezas identificadas." },
|
|
2203
|
+
"view.none": { nivel: "error", descripcion: "Ninguna vista v\xE1lida; no se puede generar el visor." },
|
|
2204
|
+
"view.unknown-pdf-owners": { nivel: "observacion", descripcion: "Identificadores del PDF sin pieza en el IFC." },
|
|
2205
|
+
// Materials workbook
|
|
2206
|
+
"materials.invalid": { nivel: "error", descripcion: "El Excel de materiales no es legible." },
|
|
2207
|
+
"materials.sheet-missing": { nivel: "error", descripcion: "La hoja declarada no existe en el Excel." },
|
|
2208
|
+
"materials.sheet-selection": { nivel: "error", descripcion: "Varias hojas reconocibles y ninguna declarada." },
|
|
2209
|
+
// Incoherence inside a row: the file contradicts itself or the other files.
|
|
2210
|
+
"materials.formula": { nivel: "error", descripcion: "Celda con f\xF3rmula; exportar valores." },
|
|
2211
|
+
"materials.cell-error": { nivel: "error", descripcion: "Celda con error de Excel." },
|
|
2212
|
+
"materials.duplicate-columns": { nivel: "error", descripcion: "Encabezado duplicado en la hoja." },
|
|
2213
|
+
"materials.element-id-mismatch": { nivel: "error", descripcion: "El ElementId declarado no corresponde al GUID." },
|
|
2214
|
+
"materials.invalid-record-kind": { nivel: "error", descripcion: "Tipo de registro incoherente con la fila." },
|
|
2215
|
+
"materials.invalid-wbs": { nivel: "error", descripcion: "C\xF3digo WBS con formato inv\xE1lido." },
|
|
2216
|
+
"materials.wbs-unmatched": { nivel: "error", descripcion: "C\xF3digo WBS que no existe en el cat\xE1logo." },
|
|
2217
|
+
// Absence or ambiguity: reported, the row is skipped or left pending.
|
|
2218
|
+
"materials.missing-guid": { nivel: "observacion", descripcion: "Fila sin GUID anfitri\xF3n." },
|
|
2219
|
+
"materials.unknown-host": { nivel: "observacion", descripcion: "GUID anfitri\xF3n no encontrado en el IFC." },
|
|
2220
|
+
"materials.duplicate-row": { nivel: "observacion", descripcion: "Fila repetida en la hoja." },
|
|
2221
|
+
"materials.invalid-row": { nivel: "observacion", descripcion: "N\xFAmero de fila inv\xE1lido." },
|
|
2222
|
+
"materials.wbs-no-code": { nivel: "observacion", descripcion: "Fila sin c\xF3digo WBS." },
|
|
2223
|
+
"materials.wbs-ambiguous": { nivel: "observacion", descripcion: "C\xF3digo corto con varios candidatos en el cat\xE1logo." },
|
|
2224
|
+
"materials.quantity-unavailable": { nivel: "observacion", descripcion: "Sin cantidad compatible con la unidad del concepto." },
|
|
2225
|
+
"materials.ifc-workbook-quantity-discrepancy": { nivel: "observacion", descripcion: "Cantidad IFC y Excel difieren; queda pendiente." },
|
|
2226
|
+
"materials.conflicting-element-quantity": { nivel: "observacion", descripcion: "Filas de pieza con cantidades contradictorias." },
|
|
2227
|
+
"materials.conflicting-element-relation": { nivel: "observacion", descripcion: "Filas de pieza con partidas contradictorias." },
|
|
2228
|
+
"materials.excel-only-material": { nivel: "observacion", descripcion: "Material documentado solo en Excel." },
|
|
2229
|
+
"materials.issue": { nivel: "observacion", descripcion: "Otra observaci\xF3n del Excel de materiales." },
|
|
2230
|
+
// Relations and pipeline
|
|
2231
|
+
"relations.pending": { nivel: "observacion", descripcion: "Relaciones ambiguas o pendientes de resolver." },
|
|
2232
|
+
"pipeline.warning": { nivel: "observacion", descripcion: "Aviso del procesamiento." },
|
|
2233
|
+
"pipeline.failed": { nivel: "error", descripcion: "El procesamiento fall\xF3." }
|
|
2234
|
+
};
|
|
2235
|
+
var defaultPolicy = Object.freeze(
|
|
2236
|
+
Object.fromEntries(
|
|
2237
|
+
Object.entries(rules).map(([id, rule]) => [id, rule.nivel])
|
|
2238
|
+
)
|
|
2239
|
+
);
|
|
2240
|
+
function isRuleId(value2) {
|
|
2241
|
+
return Object.hasOwn(rules, value2);
|
|
2242
|
+
}
|
|
2243
|
+
function levelFor(rule, policy = defaultPolicy) {
|
|
2244
|
+
if (isRuleId(rule)) return policy[rule] ?? rules[rule].nivel;
|
|
2245
|
+
return "observacion";
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
// src/contract/validate.ts
|
|
2249
|
+
var reservedFileNames = /* @__PURE__ */ new Set([
|
|
2250
|
+
"entrega.json",
|
|
2251
|
+
"validacion.json",
|
|
2252
|
+
"ficha-entrega.md",
|
|
2253
|
+
"log-exportacion.txt"
|
|
2254
|
+
]);
|
|
2255
|
+
var sha2562 = (bytes) => createHash2("sha256").update(bytes).digest("hex");
|
|
2256
|
+
var messageOf = (error) => error instanceof Error ? error.message : String(error);
|
|
2257
|
+
async function validateDelivery(input) {
|
|
2258
|
+
return (await validateDeliveryDetailed(input)).report;
|
|
2259
|
+
}
|
|
2260
|
+
async function validateDeliveryDetailed(input) {
|
|
2261
|
+
const policy = input.policy ?? defaultPolicy;
|
|
2262
|
+
const findings = [];
|
|
2263
|
+
const metricas = {};
|
|
2264
|
+
let config = null;
|
|
2265
|
+
let processed = null;
|
|
2266
|
+
const add3 = (finding) => findings.push({ ...finding, nivel: levelFor(finding.regla, policy) });
|
|
2267
|
+
const finish = (manifest3, archivos2) => {
|
|
2268
|
+
const report = buildReport({ manifest: manifest3, archivos: archivos2, findings, metricas });
|
|
2269
|
+
return { report, config, processed: report.veredicto === "aceptada" ? processed : null };
|
|
2270
|
+
};
|
|
2271
|
+
const parsed = parseManifest(input.manifest);
|
|
2272
|
+
if (!parsed.ok) {
|
|
2273
|
+
for (const finding of parsed.findings) add3(finding);
|
|
2274
|
+
return finish(null, []);
|
|
2275
|
+
}
|
|
2276
|
+
const manifest2 = parsed.manifest;
|
|
2277
|
+
const byName = new Map(input.files.map((file) => [file.name, file]));
|
|
2278
|
+
const archivos = [];
|
|
2279
|
+
let integrityFailed = false;
|
|
2280
|
+
for (const declared of manifest2.archivos) {
|
|
2281
|
+
const file = byName.get(declared.archivo);
|
|
2282
|
+
if (!file) {
|
|
2283
|
+
integrityFailed = true;
|
|
2284
|
+
add3({
|
|
2285
|
+
regla: "delivery.file-missing",
|
|
2286
|
+
archivo: declared.archivo,
|
|
2287
|
+
mensaje: "Archivo declarado en entrega.json pero ausente en la entrega."
|
|
2288
|
+
});
|
|
2289
|
+
archivos.push({ rol: declared.rol, archivo: declared.archivo, sha256: declared.sha256, bytes: 0 });
|
|
2290
|
+
continue;
|
|
2291
|
+
}
|
|
2292
|
+
const actual = sha2562(file.bytes);
|
|
2293
|
+
archivos.push({ rol: declared.rol, archivo: declared.archivo, sha256: actual, bytes: file.bytes.length });
|
|
2294
|
+
if (actual !== declared.sha256) {
|
|
2295
|
+
integrityFailed = true;
|
|
2296
|
+
add3({
|
|
2297
|
+
regla: "delivery.hash-mismatch",
|
|
2298
|
+
archivo: declared.archivo,
|
|
2299
|
+
mensaje: `La huella SHA-256 del contenido (${actual.slice(0, 12)}\u2026) no coincide con la declarada (${declared.sha256.slice(0, 12)}\u2026).`
|
|
2300
|
+
});
|
|
2301
|
+
}
|
|
2302
|
+
}
|
|
2303
|
+
for (const file of input.files) {
|
|
2304
|
+
if (reservedFileNames.has(file.name) || manifest2.archivos.some((d) => d.archivo === file.name)) continue;
|
|
2305
|
+
add3({
|
|
2306
|
+
regla: "delivery.file-undeclared",
|
|
2307
|
+
archivo: file.name,
|
|
2308
|
+
mensaje: "Archivo presente en la entrega pero no declarado en entrega.json; se ignora."
|
|
2309
|
+
});
|
|
2310
|
+
}
|
|
2311
|
+
if (integrityFailed) return finish(manifest2, archivos);
|
|
2312
|
+
const bytesOf = (file) => byName.get(file.archivo).bytes;
|
|
2313
|
+
const catalogFile = manifest2.archivos.find((f) => f.rol === "catalogo");
|
|
2314
|
+
const modelFile = manifest2.archivos.find((f) => f.rol === "modelo");
|
|
2315
|
+
const materialsFile = manifest2.archivos.find((f) => f.rol === "materiales");
|
|
2316
|
+
const { pdfIds } = planoSources(manifest2);
|
|
2317
|
+
const pdfs = [...pdfIds.entries()].map(([plano2, id]) => ({ id, name: plano2.archivo, bytes: bytesOf(plano2) }));
|
|
2318
|
+
const materials = materialsFile ? {
|
|
2319
|
+
id: "materials",
|
|
2320
|
+
name: materialsFile.archivo,
|
|
2321
|
+
role: "materials",
|
|
2322
|
+
sha256: materialsFile.sha256,
|
|
2323
|
+
bytes: bytesOf(materialsFile)
|
|
2324
|
+
} : void 0;
|
|
2325
|
+
let catalog;
|
|
2326
|
+
try {
|
|
2327
|
+
catalog = readHouseCatalog(bytesOf(catalogFile));
|
|
2328
|
+
} catch (error) {
|
|
2329
|
+
add3({ regla: "catalog.invalid", archivo: catalogFile.archivo, mensaje: messageOf(error) });
|
|
2330
|
+
return finish(manifest2, archivos);
|
|
2331
|
+
}
|
|
2332
|
+
try {
|
|
2333
|
+
modelRevision(bytesOf(modelFile));
|
|
2334
|
+
} catch (error) {
|
|
2335
|
+
add3({ regla: "model.invalid", archivo: modelFile.archivo, mensaje: messageOf(error) });
|
|
2336
|
+
return finish(manifest2, archivos);
|
|
2337
|
+
}
|
|
2338
|
+
let inspection;
|
|
2339
|
+
try {
|
|
2340
|
+
inspection = await inspectDelivery({ catalogBytes: bytesOf(catalogFile), modelBytes: bytesOf(modelFile), pdfs, materials });
|
|
2341
|
+
} catch (error) {
|
|
2342
|
+
add3({ regla: "pipeline.failed", mensaje: `La inspecci\xF3n de la entrega fall\xF3: ${messageOf(error)}` });
|
|
2343
|
+
return finish(manifest2, archivos);
|
|
2344
|
+
}
|
|
2345
|
+
metricas.catalogConcepts = inspection.catalog.conceptCount;
|
|
2346
|
+
metricas.catalogRoots = inspection.catalog.roots;
|
|
2347
|
+
if (inspection.model) metricas.modelElements = inspection.model.elementCount;
|
|
2348
|
+
let declaredViewsValid = true;
|
|
2349
|
+
for (const [plano2, id] of pdfIds) {
|
|
2350
|
+
const pages = inspection.pdfs.find((pdf) => pdf.id === id)?.pages ?? 0;
|
|
2351
|
+
if (plano2.pagina > pages) {
|
|
2352
|
+
declaredViewsValid = false;
|
|
2353
|
+
add3({
|
|
2354
|
+
regla: "view.page-out-of-range",
|
|
2355
|
+
archivo: plano2.archivo,
|
|
2356
|
+
mensaje: `Se declar\xF3 la p\xE1gina ${plano2.pagina} pero el PDF tiene ${pages} p\xE1gina(s).`
|
|
2357
|
+
});
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
if (materialsFile?.hoja && inspection.materials?.sheets) {
|
|
2361
|
+
if (!inspection.materials.sheets.some((sheet) => sheet.name === materialsFile.hoja))
|
|
2362
|
+
add3({
|
|
2363
|
+
regla: "materials.sheet-missing",
|
|
2364
|
+
archivo: materialsFile.archivo,
|
|
2365
|
+
hoja: materialsFile.hoja,
|
|
2366
|
+
mensaje: `La hoja declarada "${materialsFile.hoja}" no existe en el Excel de materiales.`
|
|
2367
|
+
});
|
|
2368
|
+
}
|
|
2369
|
+
if (!declaredViewsValid) return finish(manifest2, archivos);
|
|
2370
|
+
const derived = deriveImportConfig(manifest2, inspection.catalog);
|
|
2371
|
+
config = derived;
|
|
2372
|
+
try {
|
|
2373
|
+
validateHouseConfig(derived, catalog, pdfs);
|
|
2374
|
+
} catch (error) {
|
|
2375
|
+
add3({ regla: "pipeline.failed", mensaje: `La configuraci\xF3n derivada no es v\xE1lida: ${messageOf(error)}` });
|
|
2376
|
+
return finish(manifest2, archivos);
|
|
2377
|
+
}
|
|
2378
|
+
let audit;
|
|
2379
|
+
try {
|
|
2380
|
+
const result = await processDelivery({
|
|
2381
|
+
houseId: "validacion",
|
|
2382
|
+
revisionId: "validacion",
|
|
2383
|
+
name: manifest2.proyecto,
|
|
2384
|
+
catalogBytes: bytesOf(catalogFile),
|
|
2385
|
+
modelBytes: bytesOf(modelFile),
|
|
2386
|
+
pdfs,
|
|
2387
|
+
config: derived,
|
|
2388
|
+
materials,
|
|
2389
|
+
// Hosts publish assets by name; the viewer resolves them to URLs at load time.
|
|
2390
|
+
assetUrl: (fileName2) => fileName2
|
|
2391
|
+
});
|
|
2392
|
+
audit = result.audit;
|
|
2393
|
+
processed = result;
|
|
2394
|
+
} catch (error) {
|
|
2395
|
+
const message = messageOf(error);
|
|
2396
|
+
if (message.startsWith("No se pudo generar ninguna vista interactiva"))
|
|
2397
|
+
add3({ regla: "view.none", mensaje: message });
|
|
2398
|
+
else if (message.startsWith("Selecciona una hoja"))
|
|
2399
|
+
add3({ regla: "materials.sheet-selection", archivo: materialsFile?.archivo, mensaje: message });
|
|
2400
|
+
else add3({ regla: "pipeline.failed", mensaje: message });
|
|
2401
|
+
return finish(manifest2, archivos);
|
|
2402
|
+
}
|
|
2403
|
+
const rejectedReasons = /* @__PURE__ */ new Set();
|
|
2404
|
+
for (const rejected of audit.rejectedViews ?? []) {
|
|
2405
|
+
rejectedReasons.add(rejected.reason);
|
|
2406
|
+
const plano2 = [...pdfIds.entries()].find(([, id]) => id === rejected.pdfId)?.[0];
|
|
2407
|
+
add3({
|
|
2408
|
+
regla: "view.rejected",
|
|
2409
|
+
archivo: plano2?.archivo ?? rejected.pdfId,
|
|
2410
|
+
mensaje: rejected.reason,
|
|
2411
|
+
datos: { pagina: rejected.page }
|
|
2412
|
+
});
|
|
2413
|
+
}
|
|
2414
|
+
for (const view of audit.views ?? []) {
|
|
2415
|
+
const unknown = view.unknownOwners?.length ?? 0;
|
|
2416
|
+
if (!unknown) continue;
|
|
2417
|
+
add3({
|
|
2418
|
+
regla: "view.unknown-pdf-owners",
|
|
2419
|
+
archivo: view.source?.name,
|
|
2420
|
+
mensaje: `${unknown} identificador(es) del PDF no existen en el IFC; permanecen como fondo sin interacci\xF3n.`,
|
|
2421
|
+
datos: { pagina: view.page, cantidad: unknown }
|
|
2422
|
+
});
|
|
2423
|
+
}
|
|
2424
|
+
for (const code of audit.unmatchedCodes ?? []) {
|
|
2425
|
+
const owners = (audit.elements ?? []).filter((element) => element.wbs?.includes(code));
|
|
2426
|
+
add3({
|
|
2427
|
+
regla: "model.wbs-unmatched",
|
|
2428
|
+
archivo: modelFile.archivo,
|
|
2429
|
+
mensaje: `El c\xF3digo WBS "${code}" declarado en ${owners.length} pieza(s) del IFC no existe en el cat\xE1logo.`,
|
|
2430
|
+
datos: { codigo: code, piezas: owners.length, ejemplos: owners.slice(0, 5).map((e) => e.elementId) }
|
|
2431
|
+
});
|
|
2432
|
+
}
|
|
2433
|
+
const withoutWbs = (audit.elements ?? []).filter((element) => !element.wbs?.length).length;
|
|
2434
|
+
if (withoutWbs)
|
|
2435
|
+
add3({
|
|
2436
|
+
regla: "model.without-wbs",
|
|
2437
|
+
archivo: modelFile.archivo,
|
|
2438
|
+
mensaje: `${withoutWbs} pieza(s) del IFC no declaran WBS propio; solo reciben las relaciones expl\xEDcitas del Excel.`,
|
|
2439
|
+
datos: { piezas: withoutWbs }
|
|
2440
|
+
});
|
|
2441
|
+
for (const record of audit.materials?.records ?? []) {
|
|
2442
|
+
for (const issue of record.issues ?? []) {
|
|
2443
|
+
const regla = `materials.${issue}`;
|
|
2444
|
+
add3({
|
|
2445
|
+
regla: isRuleId(regla) ? regla : "materials.issue",
|
|
2446
|
+
archivo: materialsFile?.archivo,
|
|
2447
|
+
hoja: record.sheet,
|
|
2448
|
+
fila: record.row,
|
|
2449
|
+
guid: record.hostId,
|
|
2450
|
+
mensaje: materialsIssueMessage(issue, record),
|
|
2451
|
+
datos: { incidencia: issue }
|
|
2452
|
+
});
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
const counts = audit.counts ?? {};
|
|
2456
|
+
Object.assign(metricas, counts);
|
|
2457
|
+
metricas.elementsWithoutWbs = withoutWbs;
|
|
2458
|
+
metricas.rejectedViews = audit.rejectedViews?.length ?? 0;
|
|
2459
|
+
metricas.materialsRecords = audit.materials?.records?.length ?? 0;
|
|
2460
|
+
metricas.materialsRecordsWithIssues = (audit.materials?.records ?? []).filter((r) => r.issues?.length).length;
|
|
2461
|
+
if ((counts.pendingRelations ?? 0) + (counts.ambiguousRelations ?? 0) > 0)
|
|
2462
|
+
add3({
|
|
2463
|
+
regla: "relations.pending",
|
|
2464
|
+
mensaje: `${counts.pendingRelations ?? 0} relaci\xF3n(es) pendientes y ${counts.ambiguousRelations ?? 0} ambiguas requieren resolver la partida en origen.`
|
|
2465
|
+
});
|
|
2466
|
+
for (const warning of audit.warnings ?? []) {
|
|
2467
|
+
if (rejectedReasons.has(warning)) continue;
|
|
2468
|
+
if (/^\d+ códigos IFC no tienen coincidencia exacta/.test(warning)) continue;
|
|
2469
|
+
if (/^\d+ elementos IFC no declaran WBS propio/.test(warning)) continue;
|
|
2470
|
+
if (/^Moneda sin confirmar/.test(warning)) continue;
|
|
2471
|
+
if (/identificadores del PDF no existen en el IFC/.test(warning)) continue;
|
|
2472
|
+
add3({ regla: "pipeline.warning", mensaje: warning });
|
|
2473
|
+
}
|
|
2474
|
+
return finish(manifest2, archivos);
|
|
2475
|
+
}
|
|
2476
|
+
function materialsIssueMessage(issue, record) {
|
|
2477
|
+
const subject = record.recordKind === "material" ? "Aplicaci\xF3n de material" : "Fila de pieza";
|
|
2478
|
+
switch (issue) {
|
|
2479
|
+
case "unknown-host":
|
|
2480
|
+
return `${subject}: el GUID anfitri\xF3n no existe en el IFC de la entrega.`;
|
|
2481
|
+
case "wbs-no-code":
|
|
2482
|
+
return `${subject}: la fila no declara c\xF3digo WBS.`;
|
|
2483
|
+
case "quantity-unavailable":
|
|
2484
|
+
return `${subject}: no hay cantidad exportada compatible con la unidad del concepto.`;
|
|
2485
|
+
case "ifc-workbook-quantity-discrepancy":
|
|
2486
|
+
return `${subject}: la cantidad del IFC y la del Excel difieren; queda pendiente sin conciliar.`;
|
|
2487
|
+
case "excel-only-material":
|
|
2488
|
+
return `${subject}: documentado solo en el Excel, sin asociaci\xF3n de material en el IFC.`;
|
|
2489
|
+
default:
|
|
2490
|
+
return `${subject}: ${issue}.`;
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
function buildReport(input) {
|
|
2494
|
+
const sorted = sortFindings(input.findings);
|
|
2495
|
+
const errores = sorted.filter((f) => f.nivel === "error");
|
|
2496
|
+
const observaciones = sorted.filter((f) => f.nivel === "observacion");
|
|
2497
|
+
const huella = input.archivos.length ? sha2562(
|
|
2498
|
+
Buffer.from(
|
|
2499
|
+
input.archivos.map((file) => `${file.rol} ${file.archivo} ${file.sha256}`).sort().join("\n")
|
|
2500
|
+
)
|
|
2501
|
+
) : null;
|
|
2502
|
+
return {
|
|
2503
|
+
schemaVersion: 1,
|
|
2504
|
+
validatorVersion,
|
|
2505
|
+
policyVersion,
|
|
2506
|
+
entrega: {
|
|
2507
|
+
proyecto: input.manifest?.proyecto ?? null,
|
|
2508
|
+
revision: input.manifest?.revision ?? null,
|
|
2509
|
+
fecha: input.manifest?.fecha ?? null,
|
|
2510
|
+
huella,
|
|
2511
|
+
archivos: input.archivos
|
|
2512
|
+
},
|
|
2513
|
+
veredicto: errores.length ? "rechazada" : "aceptada",
|
|
2514
|
+
errores,
|
|
2515
|
+
observaciones,
|
|
2516
|
+
metricas: Object.fromEntries(Object.entries(input.metricas).sort(([a], [b]) => a.localeCompare(b)))
|
|
2517
|
+
};
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
// src/cli/validate-delivery.ts
|
|
2521
|
+
function parseArgs(argv) {
|
|
2522
|
+
let dir;
|
|
2523
|
+
let output = "validacion.json";
|
|
2524
|
+
let json = false;
|
|
2525
|
+
for (let i = 0; i < argv.length; i++) {
|
|
2526
|
+
const arg = argv[i];
|
|
2527
|
+
if (arg === "--json") json = true;
|
|
2528
|
+
else if (arg === "--salida" || arg === "--output") output = argv[++i] ?? output;
|
|
2529
|
+
else if (arg.startsWith("-")) return null;
|
|
2530
|
+
else if (dir === void 0) dir = arg;
|
|
2531
|
+
else return null;
|
|
2532
|
+
}
|
|
2533
|
+
return dir === void 0 ? null : { dir: resolve(dir), output, json };
|
|
2534
|
+
}
|
|
2535
|
+
async function readDeliveryFolder(dir) {
|
|
2536
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
2537
|
+
const files = [];
|
|
2538
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
2539
|
+
if (!entry.isFile() || entry.name.startsWith(".")) continue;
|
|
2540
|
+
files.push({ name: entry.name, bytes: new Uint8Array(await readFile(join(dir, entry.name))) });
|
|
2541
|
+
}
|
|
2542
|
+
const manifestFile = files.find((file) => file.name === "entrega.json");
|
|
2543
|
+
if (!manifestFile) throw new Error(`No se encontr\xF3 entrega.json en ${dir}`);
|
|
2544
|
+
let manifest2;
|
|
2545
|
+
try {
|
|
2546
|
+
manifest2 = JSON.parse(Buffer.from(manifestFile.bytes).toString("utf8"));
|
|
2547
|
+
} catch (error) {
|
|
2548
|
+
throw new Error(`entrega.json no es JSON v\xE1lido: ${error instanceof Error ? error.message : String(error)}`);
|
|
2549
|
+
}
|
|
2550
|
+
return { manifest: manifest2, files: files.filter((file) => !reservedFileNames.has(file.name)) };
|
|
2551
|
+
}
|
|
2552
|
+
async function main(argv = process.argv.slice(2)) {
|
|
2553
|
+
const options = parseArgs(argv);
|
|
2554
|
+
if (!options) {
|
|
2555
|
+
process.stderr.write("Uso: validate-delivery <carpeta> [--salida validacion.json] [--json]\n");
|
|
2556
|
+
return 2;
|
|
2557
|
+
}
|
|
2558
|
+
if (!(await stat(options.dir).catch(() => null))?.isDirectory()) {
|
|
2559
|
+
process.stderr.write(`La carpeta no existe: ${options.dir}
|
|
2560
|
+
`);
|
|
2561
|
+
return 2;
|
|
2562
|
+
}
|
|
2563
|
+
const { manifest: manifest2, files } = await readDeliveryFolder(options.dir);
|
|
2564
|
+
const report = await validateDelivery({
|
|
2565
|
+
manifest: manifest2,
|
|
2566
|
+
files: files.filter((file) => file.name !== options.output)
|
|
2567
|
+
});
|
|
2568
|
+
const serialized = `${JSON.stringify(report, null, 2)}
|
|
2569
|
+
`;
|
|
2570
|
+
await writeFile(resolve(options.dir, options.output), serialized, "utf8");
|
|
2571
|
+
process.stdout.write(options.json ? serialized : `${formatReport(report)}
|
|
2572
|
+
|
|
2573
|
+
Reporte escrito en ${options.output}
|
|
2574
|
+
`);
|
|
2575
|
+
return report.veredicto === "aceptada" ? 0 : 1;
|
|
2576
|
+
}
|
|
2577
|
+
|
|
2578
|
+
// src/cli/entry.ts
|
|
2579
|
+
main().then(
|
|
2580
|
+
(code) => process.exit(code),
|
|
2581
|
+
(error) => {
|
|
2582
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}
|
|
2583
|
+
`);
|
|
2584
|
+
process.exit(2);
|
|
2585
|
+
}
|
|
2586
|
+
);
|