/**
* Rysunek wymiarowy (blueprint) — Etap C.
*
* Tryby porównywane zgodnie z briefem §8:
* - "svg" — deterministyczny, wymiary WYŁĄCZNIE z faktów confirmed
* - "hybrid" — AI rysuje czystą kreskę mebla, wymiary dokładane programowo
* - "ai" — czysta ilustracja AI z wymiarami (test ryzyka halucynacji liczb)
*
* Zasada: blueprint nigdy nie wymyśla wymiarów. Brakujące dane są oznaczone
* jako zarys poglądowy, nie jako liczby.
*/
import type { Db } from "../db.js";
import { approvedAssetForRole } from "./assets.js";
import type { ShotRole } from "./types.js";
export interface BlueprintDims {
width: number | null; // gabarytowa szerokość
depth: number | null; // gabarytowa głębokość (z szezlongiem)
heightCushions: number | null; // wysokość z poduszkami
heightNoCushions: number | null; // wysokość bez poduszek
seatHeight: number | null;
seatDepth: number | null;
sleepWidth: number | null;
sleepLength: number | null;
armrestWidth: number | null;
legsHeight: number | null;
bodyHeight: number | null; // wys. korpusu — punkt pomiaru niejednoznaczny, do tabelki
bodyDepth: number | null;
cushionCount: number | null; // luźne poduszki oparciowe
// opcjonalne — pojawiają się na rysunku dopiero po potwierdzeniu faktu
seatSectionWidth: number | null; // szer. części siedziskowej (np. 128)
chaiseWidth: number | null; // szer. szezlongu (np. 130)
chaiseSeatDepth: number | null; // gł. siedziska szezlongu (np. 107)
armrestHeight: number | null; // wys. podłokietnika (np. 67/99)
side: "L" | "P" | null; // strona szezlongu fotografowanego egzemplarza
}
/** Czyta tylko fakty confirmed — niepotwierdzone wymiary nie wchodzą na rysunek. */
export function confirmedDims(db: Db, productId: string): BlueprintDims {
const get = (key: string): number | null => {
const r = db
.prepare(
"SELECT value_json FROM product_facts WHERE product_id = ? AND key = ? AND status = 'confirmed'"
)
.get(productId, key) as unknown as { value_json: string } | undefined;
if (!r) return null;
const v = JSON.parse(r.value_json) as unknown;
return typeof v === "number" ? v : null;
};
const sideRow = db
.prepare(
"SELECT value_json FROM product_facts WHERE product_id = ? AND key = 'sides.photographed' AND status = 'confirmed'"
)
.get(productId) as unknown as { value_json: string } | undefined;
const side = sideRow ? (JSON.parse(sideRow.value_json) as "L" | "P") : null;
return {
width: get("dimensions.width_cm"),
depth: get("dimensions.depth_cm"),
heightCushions: get("dimensions.height_with_cushions_cm"),
heightNoCushions: get("dimensions.height_without_cushions_cm"),
seatHeight: get("dimensions.seat_height_cm"),
seatDepth: get("dimensions.seat_depth_cm"),
sleepWidth: get("sleeping.width_cm"),
sleepLength: get("sleeping.length_cm"),
armrestWidth: get("dimensions.armrest_width_cm"),
legsHeight: get("legs.height_cm"),
bodyHeight: get("dimensions.body_height_cm"),
bodyDepth: get("dimensions.body_depth_cm"),
cushionCount: get("cushions.loose_back_count") ?? get("cushions.count"),
seatSectionWidth: get("dimensions.seat_section_width_cm") ?? get("dimensions.center_seat_width_cm"),
chaiseWidth: get("dimensions.chaise_width_cm") ?? get("dimensions.meridian_seat_width_cm"),
chaiseSeatDepth: get("dimensions.chaise_seat_depth_cm") ?? get("dimensions.meridian_seat_depth_cm"),
armrestHeight: get("dimensions.armrest_height_cm"),
side,
};
}
// ---------- wspólne elementy kreślarskie ----------
const INK = "#1c2733";
const ACCENT = "#b3541e";
const LIGHT = "#f7f5f0";
const DIMLINE = "#5a6b7a";
/** Pozioma linia wymiarowa z liniami pomocniczymi (witness lines). */
function dimArrowH(x: number, y: number, w: number, label: string, witY?: number): string {
const wit = witY !== undefined
? `
`
: "";
return `
${wit}
${label}`;
}
/** Pionowa linia wymiarowa z liniami pomocniczymi. */
function dimArrowV(x: number, y: number, h: number, label: string, witX?: number): string {
const wit = witX !== undefined
? `
`
: "";
return `
${wit}
${label}`;
}
function title(x: number, y: number, t: string): string {
return `${t}`;
}
const cm = (v: number | null): string =>
v === null ? "—" : `${String(v).replace(".", ",")} cm`;
/** ISO (YYYY-MM-DD) → DD.MM.YYYY — format kliencki na arkuszach. */
const datePl = (iso: string): string =>
/^(\d{4})-(\d{2})-(\d{2})$/.exec(iso)?.slice(1).reverse().join(".") ?? iso;
const F = "Arial, sans-serif";
export interface BlueprintCtx {
productName: string;
fabricLabel: string; // np. "TILIA 86"
date: string; // np. "2026-09-15"
}
/**
* Deterministyczny SVG blueprint — elewacja mebla, nie gołe prostokąty.
* Wymiary WYŁĄCZNIE z faktów confirmed; podział bryły jest poglądowy
* i podpisany. Format 1600×1150 — telefon, marketplace, druk A4.
*/
export function buildBlueprintSvg(dims: BlueprintDims, ctx: BlueprintCtx): string {
const W = 1600;
const H = 1150;
const chaiseRight = dims.side !== "L";
const hasChaise = dims.side !== null; // sofa/fotel (side=null) → obrys prostokątny
// ================= RZUT Z GÓRY (lewa kolumna) =================
const s = 1.7;
const topW = (dims.width ?? 257) * s; // 437
const topD = (dims.depth ?? 151) * s; // 257
const topX = 170;
const topY = 210;
const seatW = hasChaise ? topW * 0.62 : topW; // część siedziskowa (bez szezlongu) — zarys poglądowy
const bodyD = hasChaise ? topD * 0.58 : topD * 0.62; // głębokość części z oparciem — zarys poglądowy
// Obrys L: szezlong po prawej na pełną głębokość; bez szezlonga — prostokąt
const lPath = !hasChaise
? `M ${topX} ${topY} H ${topX + topW} V ${topY + topD} H ${topX} Z`
: chaiseRight
? `M ${topX} ${topY} H ${topX + topW} V ${topY + topD} H ${topX + seatW} V ${topY + bodyD} H ${topX} Z`
: `M ${topX} ${topY} H ${topX + topW} V ${topY + bodyD} H ${topX + topW - seatW} V ${topY + topD} H ${topX} Z`;
const lPathIn = !hasChaise
? `M ${topX + 6} ${topY + 6} H ${topX + topW - 6} V ${topY + topD - 6} H ${topX + 6} Z`
: chaiseRight
? `M ${topX + 6} ${topY + 6} H ${topX + topW - 6} V ${topY + topD - 6} H ${topX + seatW + 6} V ${topY + bodyD + 6} H ${topX + 6} Z`
: `M ${topX + 6} ${topY + 6} H ${topX + topW - 6} V ${topY + bodyD + 6} H ${topX + topW - seatW - 6} V ${topY + topD - 6} H ${topX + 6} Z`;
// oparcie: pas wzdłuż górnej krawędzi części siedziskowej; podziały poduszek
const backD = 34;
const seatPartX = chaiseRight ? topX : topX + topW - seatW;
const cushionDivs = [1, 2, 3, 4]
.map((i) => {
const cx = seatPartX + (seatW / 5) * i;
return ``;
})
.join("");
const topView = `
${title(topX, topY - 44, "RZUT Z GÓRY")}
${cushionDivs}
${hasChaise ? `szezlong (${dims.side ?? "P"})` : ""}
oparcie
${dimArrowH(topX, topY + topD + 44, topW, cm(dims.width), topY + topD)}
${dimArrowV(topX + topW + 46, topY, topD, cm(dims.depth), topX + topW)}
zarys poglądowy — gabaryty potwierdzone, podział bryły orientacyjny`;
// ================= WIDOK Z PRZODU (prawa kolumna) =================
const fs = 1.9;
const fW = (dims.width ?? 257) * fs; // 488
const fH = (dims.heightCushions ?? 88) * fs; // 167
const fX = 830;
const baseY = 500; // linia podłogi
const fY = baseY - fH;
const seatY = baseY - (dims.seatHeight ?? 46) * fs; // poziom siedziska
const armW = 58;
const armTop = baseY - fH * 0.72;
// Liczba poduszek wyłącznie z potwierdzonego faktu — bez faktu rysunek
// pokazuje jednolite oparcie, a nie wymyślony podział.
const cushionCount = dims.cushionCount;
const innerW = fW - 2 * armW;
const cushions = cushionCount
? Array.from({ length: cushionCount }, (_, i) => {
const cushW = innerW / cushionCount;
const cx = fX + armW + i * cushW;
return ``;
}).join("")
: ``;
const frontView = `
${title(fX, fY - 44, "WIDOK Z PRZODU")}
${cushions}
${dimArrowH(fX, baseY + 44, fW, cm(dims.width), baseY)}
${dimArrowV(fX + fW + 46, fY, fH, cm(dims.heightCushions), fX + fW)}
${dims.seatHeight !== null ? dimArrowV(fX - 46, seatY, baseY - seatY, cm(dims.seatHeight), fX) : ""}
wys. z poduszkami ${cm(dims.heightCushions)} · bez poduszek ${cm(dims.heightNoCushions)} · gł. siedziska ${cm(dims.seatDepth)}`;
// ================= POWIERZCHNIA SPANIA (lewy dół) =================
const ss = 2.1;
const slW = (dims.sleepLength ?? 190) * ss; // poziomo = długość
const slH = (dims.sleepWidth ?? 135) * ss;
const slX = 170;
const slY = 700;
const splitX = slX + slW * 0.62; // granica wózka / szezlonga — poglądowo
const arrowY = slY + slH / 2;
const sleepView = `
${title(slX, slY - 40, "POWIERZCHNIA SPANIA")}
${hasChaise ? `
wysuwany wózek` : ""}
${dimArrowH(slX, slY + slH + 42, slW, cm(dims.sleepLength), slY + slH)}
${dimArrowV(slX + slW + 46, slY, slH, cm(dims.sleepWidth), slX + slW)}`;
// ================= TABELKA RYSUNKOWA (prawy dół) =================
const tbX = 830;
const tbY = 700;
const tbW = 640;
const rows: Array<[string, string]> = [
["PRODUKT", ctx.productName],
["TKANINA / ODCIEŃ", ctx.fabricLabel],
["STRONA", dims.side === "L" ? "lewa (L)" : dims.side === "P" ? "prawa (P)" : "—"],
["GABARYTY", `${cm(dims.width)} × ${cm(dims.depth)} × ${cm(dims.heightCushions)}`],
["SPANIE", `${cm(dims.sleepWidth)} × ${cm(dims.sleepLength)}`],
["DATA", datePl(ctx.date)],
];
const rowH = 40;
const tbRows = rows
.map(
([k, v], i) => `
${k}
${v}`
)
.join("");
const titleBlock = `
${title(tbX, tbY - 40, "DANE RYSUNKU")}
${tbRows}
Ilustracja poglądowa — nie zastępuje rysunku technicznego producenta.`;
return ``;
}
/**
* Pasek wymiarów dla trybu hybrydowego — doklejany programowo pod
* czystą kreską AI. Liczby z faktów confirmed, nigdy z pikseli obrazu.
*/
export function dimensionStripSvg(imgWidth: number, dims: BlueprintDims): string {
const h = 132;
const items: Array<[string, string]> = [
["szerokość", cm(dims.width)],
["głębokość", cm(dims.depth)],
["wysokość", cm(dims.heightCushions)],
["wys. siedziska", cm(dims.seatHeight)],
["pow. spania", `${cm(dims.sleepWidth)} × ${cm(dims.sleepLength)}`.replace(" cm × ", " × ")],
];
const col = imgWidth / items.length;
const cells = items
.map(
([label, val], i) => `
${label.toUpperCase()}
${val}`
)
.join("");
const seps = items
.slice(1)
.map((_, i) => ``)
.join("");
return ``;
}
// ================= generyczny model widoków (composed) =================
export type BlueprintView = "top" | "front" | "sleep";
export const BLUEPRINT_VIEW_TITLES: Record = {
top: "RZUT Z GÓRY",
front: "WIDOK Z PRZODU",
sleep: "POWIERZCHNIA SPANIA",
};
/**
* Widoki karty wynikają z potwierdzonych faktów, nie z konkretnego modelu.
* Panel spania istnieje tylko dla mebla z potwierdzoną powierzchnią spania.
*/
export function planBlueprintViews(dims: BlueprintDims): BlueprintView[] {
const views: BlueprintView[] = ["top", "front"];
if (dims.sleepWidth !== null && dims.sleepLength !== null) views.push("sleep");
return views;
}
export interface BlueprintPromptCtx {
productName: string;
kind: string; // products.kind
side: "L" | "P" | null; // sides.photographed (confirmed)
cushionCount: number | null; // cushions.loose_back_count (confirmed)
mechanism: string | null; // mechanism.sleep (confirmed enum)
}
/** Opis bryły per products.kind — mapa rozszerzalna przy nowych rodzajach mebli. */
const KIND_FORM: Record<
string,
{ form: string; footprint: (side: "L" | "P" | null) => string }
> = {
"corner-sofa": {
form: "corner sofa",
footprint: (side) =>
`L-shaped footprint with the chaise lounge section on the ${side === "L" ? "left" : "right"} side as seen from the front`,
},
sofa: { form: "sofa", footprint: () => "rectangular footprint" },
armchair: { form: "armchair", footprint: () => "rectangular footprint" },
};
const GENERIC_FORM = {
form: "upholstered furniture piece",
footprint: () => "footprint exactly as shown in the reference photos",
};
/** Mechanizm rozkładania: wartość faktu mechanism.sleep → fraza EN dla promptu. */
const SLEEP_MECHANISM_EN: Record = {
seat_pullout_backfold:
"the seat section slides forward and its backrest folds down flat, forming one continuous flat sleeping surface together with the chaise section; the remaining backrest section stays upright",
dolphin:
"a pull-out section emerges from under the seat and rises to form a continuous flat sleeping surface; the backrest stays upright",
click_clack:
"the backrest folds down flat onto the seat level, forming one continuous sleeping surface",
back_fold:
"the backrest folds down flat to seat level, forming one continuous flat sleeping surface together with the seat and chaise sections",
};
const LINE_STYLE =
"Thin clean black outlines on pure white background, flat technical catalog illustration style. No text, no numbers, no dimension lines, no shading, no shadows, no props.";
/** Prompt ilustracji widoku — złożony z faktów produktu, bez cech zaszytych na sztywno. */
export function buildBlueprintViewPrompt(view: BlueprintView, ctx: BlueprintPromptCtx): string {
const k = KIND_FORM[ctx.kind] ?? GENERIC_FORM;
const cushions =
ctx.cushionCount !== null
? `${ctx.cushionCount} loose back cushions`
: "the same loose back cushions as in the reference photos";
switch (view) {
case "top":
return `Technical line illustration: strict orthographic top-down plan view of the exact ${k.form} from the reference photos. ${k.footprint(ctx.side)}. Show seat cushion divisions and the backrest strip along the rear edge. Furniture perfectly aligned to the frame, edges parallel to the frame, fills ~90% of it. ${LINE_STYLE}`;
case "front":
return `Technical line illustration: strict orthographic front elevation — perfectly flat, zero perspective — of the exact ${k.form} from the reference photos. ${cushions}, armrests and feet visible, base parallel to the bottom edge, furniture fills ~90% of frame. ${LINE_STYLE}`;
case "sleep": {
const mech =
ctx.mechanism && SLEEP_MECHANISM_EN[ctx.mechanism]
? SLEEP_MECHANISM_EN[ctx.mechanism]
: "unfolded into its sleeping configuration exactly as shown in the reference photos";
return `Technical line illustration: the exact ${k.form} from the reference photos unfolded into sleeping position — ${mech}. Slightly elevated three-quarter view showing the full sleeping surface. ${LINE_STYLE}`;
}
}
}
/** Czyta kontekst promptów z faktów confirmed + rekordu produktu. */
export function blueprintPromptCtx(db: Db, productId: string, dims: BlueprintDims): BlueprintPromptCtx {
const product = db
.prepare("SELECT name, kind FROM products WHERE id = ?")
.get(productId) as unknown as { name: string; kind: string };
const mechRow = db
.prepare(
"SELECT value_json FROM product_facts WHERE product_id = ? AND key = 'mechanism.sleep' AND status = 'confirmed'"
)
.get(productId) as unknown as { value_json: string } | undefined;
const mechanism = mechRow ? (JSON.parse(mechRow.value_json) as string) : null;
return {
productName: product.name,
kind: product.kind,
side: dims.side,
cushionCount: dims.cushionCount,
mechanism,
};
}
// ---------- dobór referencji per widok (regułowy, nie na ID) ----------
export interface BlueprintRefRule {
/** role zatwierdzonych assetów galerii, w kolejności priorytetu */
assetRoles: ShotRole[];
/** dopasowanie original_name/note źródeł product_reference (opcjonalne) */
sourceMatch?: RegExp;
/** ile najnowszych product_reference dołożyć na wypadek pustych reguł */
fallbackSources: number;
}
export const BLUEPRINT_REF_RULES: Record = {
top: {
assetRoles: ["packshot_34", "packshot_front", "side"],
sourceMatch: /top|g[óo]ra|plan/i,
fallbackSources: 1,
},
front: {
assetRoles: ["packshot_front", "packshot_34"],
sourceMatch: /front|prz[óo]d/i,
fallbackSources: 1,
},
sleep: {
assetRoles: ["sleep", "storage"],
sourceMatch: /rozlo|rozkład|mechani|spani|sleep|fold/i,
fallbackSources: 0,
},
};
/**
* Referencje widoku: zatwierdzone assety galerii (najlepsze podobieństwo
* produktu), potem źródła hali dopasowane słowami kluczowymi, na końcu
* fallback do najnowszych zdjęć produktu. Zwraca id z prefiksami src_/ast_.
*/
export function resolveViewRefs(db: Db, productId: string, view: BlueprintView): string[] {
const rule = BLUEPRINT_REF_RULES[view];
const ids: string[] = [];
for (const role of rule.assetRoles) {
const a = approvedAssetForRole(db, productId, role);
if (a) ids.push(a.id);
}
const sources = db
.prepare(
`SELECT id, original_name, note FROM source_assets
WHERE product_id = ? AND role = 'product_reference' ORDER BY created_at DESC`
)
.all(productId) as unknown as Array<{ id: string; original_name: string; note: string | null }>;
if (rule.sourceMatch) {
for (const s of sources) {
if (rule.sourceMatch.test(`${s.original_name} ${s.note ?? ""}`)) ids.push(s.id);
}
}
for (const s of sources.slice(0, rule.fallbackSources)) ids.push(s.id);
return [...new Set(ids)].slice(0, 6);
}
// ---------- layout karty i wymiarowanie ----------
export interface SheetBox {
x: number;
y: number;
w: number;
h: number;
}
/** Realny prostokąt treści ilustracji po trim+fit — kotwica wymiarów. */
export type PlacedRects = Partial>;
export interface ComposedLayout {
boxes: Partial>;
table: { x: number; y: number; w: number };
}
/** Rozkład paneli zależny od zaplanowanych widoków (2 lub 3 panele). */
export function composedLayout(views: BlueprintView[]): ComposedLayout {
if (views.includes("sleep")) {
return {
boxes: {
top: { x: 150, y: 185, w: 560, h: 330 },
front: { x: 820, y: 185, w: 660, h: 360 },
sleep: { x: 150, y: 690, w: 560, h: 330 },
},
table: { x: 820, y: 690, w: 660 },
};
}
return {
boxes: {
top: { x: 150, y: 200, w: 640, h: 400 },
front: { x: 830, y: 200, w: 640, h: 400 },
},
table: { x: 830, y: 700, w: 640 },
};
}
/** Mała pozioma linia wymiarowa wewnątrz ilustracji (callout). */
function dimInH(x: number, y: number, w: number, label: string): string {
return `
${label}`;
}
/** Mała pionowa linia wymiarowa wewnątrz ilustracji (callout). */
function dimInV(x: number, y: number, h: number, label: string, side: "l" | "r" = "r"): string {
const tx = side === "r" ? x + 8 : x - 8;
const anchor = side === "r" ? "start" : "end";
return `
${label}`;
}
/**
* Linia odniesienia z etykietą — wskazuje strefę ilustracji,
* nie udaje precyzyjnego pomiaru CAD.
*/
function leader(
tx: number,
ty: number,
px: number,
py: number,
label: string,
anchor: "start" | "end" | "middle" = "middle"
): string {
return `
${label}`;
}
export function composedSheetSvg(dims: BlueprintDims, ctx: BlueprintCtx, views: BlueprintView[]): string {
const W = 1600;
const H = 1150;
const layout = composedLayout(views);
const tbX = layout.table.x;
const tbY = layout.table.y;
const tbW = layout.table.w;
const rows: Array<[string, string]> = [
["PRODUKT", ctx.productName],
["TKANINA / ODCIEŃ", ctx.fabricLabel],
["STRONA", dims.side === "L" ? "lewa (L)" : dims.side === "P" ? "prawa (P)" : "—"],
["GABARYTY", `${cm(dims.width)} × ${cm(dims.depth)} × ${cm(dims.heightCushions)}`],
];
if (dims.bodyDepth !== null) rows.push(["GŁ. KORPUSU", cm(dims.bodyDepth)]);
if (dims.sleepWidth !== null || dims.sleepLength !== null)
rows.push(["SPANIE", `${cm(dims.sleepWidth)} × ${cm(dims.sleepLength)}`]);
if (dims.seatHeight !== null || dims.seatDepth !== null)
rows.push(["SIEDZISKO", `wys. ${cm(dims.seatHeight)} · gł. ${cm(dims.seatDepth)}`]);
if (dims.armrestWidth !== null) rows.push(["PODŁOKIETNIK", `szer. ${cm(dims.armrestWidth)}`]);
if (dims.legsHeight !== null) rows.push(["NOGI", cm(dims.legsHeight)]);
rows.push(["DATA", datePl(ctx.date)]);
const rowH = 38;
const tbRows = rows
.map(
([k, v], i) => `
${k}
${v}`
)
.join("");
return ``;
}
/**
* Nakładka wymiarowa rysowana PO nałożeniu ilustracji.
* Wszystkie łańcuchy kotwiczą do realnego prostokąta treści (placed),
* nie do ramki panelu. Wymiary wewnętrzne tylko jako:
* - łańcuch proporcjonalny — gdy ułamek wynika z faktów (ortografia),
* - leader — etykieta ze wskaźnikiem do strefy, bez udawania pomiaru.
*/
export function composedOverlaySvg(dims: BlueprintDims, placed: PlacedRects): string {
const W = 1600;
const H = 1150;
const parts: string[] = [];
const chaiseRight = dims.side !== "L"; // null → układ jak dla P (notch po lewej)
const seatFrac =
dims.width && dims.seatSectionWidth ? dims.seatSectionWidth / dims.width : 0.62;
const T = placed.top;
if (T) {
if (dims.width !== null)
parts.push(dimArrowH(T.x, T.y + T.h + 36, T.w, cm(dims.width), T.y + T.h));
// drugi rząd łańcucha: część siedziskowa | szezlong (split z faktów)
if (dims.width && dims.seatSectionWidth !== null && dims.chaiseWidth !== null && dims.side) {
const y2 = T.y + T.h + 78;
const seatX = chaiseRight ? T.x : T.x + T.w * (1 - seatFrac);
const chaiseX = chaiseRight ? T.x + T.w * seatFrac : T.x;
parts.push(dimArrowH(seatX, y2, T.w * seatFrac, cm(dims.seatSectionWidth), T.y + T.h));
parts.push(dimArrowH(chaiseX, y2, T.w * (1 - seatFrac), cm(dims.chaiseWidth), T.y + T.h));
}
if (dims.depth !== null)
parts.push(dimArrowV(T.x + T.w + 40, T.y, T.h, cm(dims.depth), T.x + T.w));
// gł. siedziska szezlonga — proporcjonalnie do głębokości gabarytowej
if (dims.depth && dims.chaiseSeatDepth !== null && dims.side) {
const cx = chaiseRight
? T.x + T.w * (seatFrac + (1 - seatFrac) / 2)
: T.x + T.w * ((1 - seatFrac) / 2);
const h = (dims.chaiseSeatDepth / dims.depth) * T.h;
parts.push(dimInV(cx, T.y + T.h - h, h, cm(dims.chaiseSeatDepth), chaiseRight ? "l" : "r"));
}
// gł. siedziska — leader z wnęki L (wolna przestrzeń w obrysie)
if (dims.seatDepth !== null && dims.side) {
const notchX = chaiseRight ? T.x + T.w * seatFrac * 0.45 : T.x + T.w * (1 - seatFrac * 0.45);
const seatZoneX = chaiseRight ? T.x + T.w * seatFrac * 0.5 : T.x + T.w * (1 - seatFrac * 0.5);
parts.push(
leader(notchX, T.y + T.h * 0.78, seatZoneX, T.y + T.h * 0.4, `gł. siedziska ${cm(dims.seatDepth)}`)
);
}
// szer. podłokietnika — proporcjonalny odcinek przy lewej krawędzi (podłokietnik zawsze na końcach)
if (dims.armrestWidth !== null && dims.width) {
const w = (dims.armrestWidth / dims.width) * T.w;
parts.push(dimInH(T.x + 6, T.y + T.h * 0.16, w, cm(dims.armrestWidth)));
}
}
const R = placed.front;
if (R) {
if (dims.width !== null)
parts.push(dimArrowH(R.x, R.y + R.h + 36, R.w, cm(dims.width), R.y + R.h));
if (dims.heightCushions !== null) {
parts.push(dimArrowV(R.x + R.w + 40, R.y, R.h, cm(dims.heightCushions), R.x + R.w));
const frac = (v: number) => (v / dims.heightCushions!) * R.h;
if (dims.heightNoCushions !== null) {
const h = frac(dims.heightNoCushions);
parts.push(dimArrowV(R.x - 40, R.y + R.h - h, h, cm(dims.heightNoCushions), R.x));
}
if (dims.seatHeight !== null) {
const h = frac(dims.seatHeight);
parts.push(dimArrowV(R.x - 78, R.y + R.h - h, h, cm(dims.seatHeight), R.x));
}
if (dims.armrestHeight !== null) {
const h = frac(dims.armrestHeight);
parts.push(dimArrowV(R.x + R.w + 78, R.y + R.h - h, h, cm(dims.armrestHeight), R.x + R.w));
}
}
// nogi — leader spod linii szerokości do dolnej krawędzi
if (dims.legsHeight !== null) {
const lx = R.x + R.w * 0.14;
parts.push(leader(lx, R.y + R.h + 84, lx, R.y + R.h - 6, `nogi ${cm(dims.legsHeight)}`));
}
// szer. podłokietnika — proporcjonalny odcinek na lewym podłokietniku
if (dims.armrestWidth !== null && dims.width) {
const w = (dims.armrestWidth / dims.width) * R.w;
parts.push(dimInH(R.x + 6, R.y + R.h * 0.42, w, cm(dims.armrestWidth)));
}
}
const S = placed.sleep;
if (S) {
if (dims.sleepLength !== null)
parts.push(dimArrowH(S.x, S.y + S.h + 36, S.w, cm(dims.sleepLength), S.y + S.h));
if (dims.sleepWidth !== null)
parts.push(dimArrowV(S.x + S.w + 40, S.y, S.h, cm(dims.sleepWidth), S.x + S.w));
}
return ``;
}
/** Prompt bazy kreskowej dla trybu hybrid (widok 3/4, bez wymiarów). */
export function buildBlueprintHybridPrompt(ctx: BlueprintPromptCtx): string {
const k = KIND_FORM[ctx.kind] ?? GENERIC_FORM;
return `Clean technical line illustration of the exact ${k.form} from the reference photos, three-quarter view. ${LINE_STYLE}`;
}
/**
* Tryb ai — AI rysuje CAŁY rysunek wymiarowy: ilustrację 3/4, linie
* wymiarowe i etykiety. To jedyna droga, w której linie trafiają w elementy
* (AI zna geometrię, którą samo narysowało).
*
* Zasady anty-halucynacyjne:
* - etykiety to cytowane literały z faktów confirmed, kopiowane verbatim
* (polski przecinek dziesiętny z cm()),
* - jawna liczba etykiet — ułatwia modelowi nie zgubienie/nie dodanie,
* - każdy wymiar ma krótką instrukcję zakotwiczenia,
* - bodyHeight NIE wchodzi do promptu (niejednoznaczny punkt pomiaru) —
* zostaje w programowej tabelce,
* - powierzchnia spania = prosty inset prostokątny, tylko gdy oba wymiary.
*/
export function buildBlueprintAiPrompt(dims: BlueprintDims, ctx: BlueprintPromptCtx): string {
const k = KIND_FORM[ctx.kind] ?? GENERIC_FORM;
const cushions =
ctx.cushionCount !== null
? `It has ${ctx.cushionCount} loose back cushions.`
: "Keep the same loose back cushions as in the reference photos.";
// (etykieta verbatim, zakotwiczenie) — kolejność = naturalny porządek czytania
const entries: Array<[string, string]> = [];
const add = (v: number | null, anchor: string) => {
if (v !== null) entries.push([cm(v), anchor]);
};
add(dims.width, "overall width — one horizontal dimension line above the furniture, spanning its full width");
add(dims.depth, "overall depth — dimension line along the receding side edge of the furniture");
add(dims.heightCushions, "overall height including back cushions — vertical dimension line on the left side");
add(dims.heightNoCushions, "furniture height without the loose back cushions — shorter vertical dimension line on the left side");
add(dims.seatHeight, "seat height — short vertical dimension line on the right side, from the floor to the seat surface");
add(dims.seatDepth, "seat depth — short dimension line on a seat cushion, from front edge to backrest");
add(dims.armrestWidth, "armrest width — short dimension line across the top of an armrest");
add(dims.armrestHeight, "armrest height — vertical dimension line beside an armrest");
add(dims.legsHeight, "leg height — short vertical dimension line beside a foot, from the floor to the furniture base");
add(dims.seatSectionWidth, "seat section width excluding the chaise — horizontal dimension line below the furniture, under the seat part");
add(dims.chaiseWidth, "chaise section width — horizontal dimension line below the furniture, under the chaise part");
add(dims.chaiseSeatDepth, "chaise seat depth — dimension line along the chaise cushion");
const list = entries.map(([label, anchor], i) => `${i + 1}. "${label}" — ${anchor}`).join("\n");
const sleepInset =
dims.sleepWidth !== null && dims.sleepLength !== null
? `\n${entries.length + 1}. In a free corner draw a small plain rectangle (top-down inset) representing the unfolded sleeping surface footprint, with "${cm(dims.sleepLength)}" on a dimension line along its longer side and "${cm(dims.sleepWidth)}" along its shorter side.`
: "";
const labelCount = entries.length + (sleepInset ? 2 : 0);
return `Technical dimension illustration of the exact ${k.form} from the reference photos — ${k.footprint(ctx.side)} — in a slightly elevated three-quarter view, like a furniture catalog dimension sheet. ${cushions}
Clean thin black line-art on a pure white background. No shading, no colors, no props, no scene. Leave generous empty margins around the furniture.
Add dimension annotations EXACTLY as listed below. Style rules: each annotation is one thin dark-gray dimension line with short perpendicular end ticks touching the measured part (dashed extension lines where helpful). Every label is LARGE, bold, sans-serif, black — written verbatim in clear empty space just above or beside its line, never overlapping the furniture lines. Copy every label character by character — these are the ONLY texts allowed in the image. Exactly ${labelCount} dimension labels. No other numbers, letters, words, symbols, logos or watermarks anywhere; empty space stays empty.
${list}${sleepInset}`;
}
// ---------- karta dla trybu ai (ilustracja + programowa tabelka) ----------
/** Region ilustracji na karcie trybu ai — worker wpasowuje wynik AI tutaj. */
export const AI_ILLUST_BOX: SheetBox = { x: 70, y: 165, w: 1000, h: 860 };
/**
* Rama karty trybu ai: nagłówek + tabelka danych (programowo, gwarantowane
* liczby) + stopka. Wymiary na ilustracji rysuje AI — tabelka jest
* jedynym programowym źródłem liczb i pokazuje też bodyHeight.
* Napisy klienckie: bez wzmianek o AI i bez fraz wewnętrznych studia.
*/
export function aiSheetSvg(dims: BlueprintDims, ctx: BlueprintCtx): string {
const W = 1600;
const H = 1150;
const tbX = 1120;
const tbY = 200;
const tbW = 400;
const rows: Array<[string, string]> = [
["PRODUKT", ctx.productName],
["TKANINA / ODCIEŃ", ctx.fabricLabel],
["STRONA", dims.side === "L" ? "lewa (L)" : dims.side === "P" ? "prawa (P)" : "—"],
["GABARYTY", `${cm(dims.width)} × ${cm(dims.depth)} × ${cm(dims.heightCushions)}`],
];
if (dims.bodyDepth !== null) rows.push(["GŁ. KORPUSU", cm(dims.bodyDepth)]);
if (dims.sleepWidth !== null || dims.sleepLength !== null)
rows.push(["SPANIE", `${cm(dims.sleepWidth)} × ${cm(dims.sleepLength)}`]);
if (dims.seatHeight !== null || dims.seatDepth !== null)
rows.push(["SIEDZISKO", `wys. ${cm(dims.seatHeight)} · gł. ${cm(dims.seatDepth)}`]);
if (dims.armrestWidth !== null) rows.push(["PODŁOKIETNIK", `szer. ${cm(dims.armrestWidth)}`]);
if (dims.legsHeight !== null) rows.push(["NOGI", cm(dims.legsHeight)]);
rows.push(["DATA", datePl(ctx.date)]);
const rowH = 38;
const tbRows = rows
.map(
([k, v], i) => `
${k}
${v}`
)
.join("");
return ``;
}
/** Reguła referencji dla trybu ai: perspektywa 3/4 jak na docelowej ilustracji. */
export const BLUEPRINT_AI_REF_RULE: BlueprintRefRule = {
assetRoles: ["packshot_34", "packshot_front", "side"],
sourceMatch: /packshot|3\/4|persp/i,
fallbackSources: 1,
};
/** Referencje trybu ai — ten sam mechanizm regułowy co resolveViewRefs. */
export function resolveBlueprintAiRefs(db: Db, productId: string): string[] {
const rule = BLUEPRINT_AI_REF_RULE;
const ids: string[] = [];
for (const role of rule.assetRoles) {
const a = approvedAssetForRole(db, productId, role);
if (a) ids.push(a.id);
}
const sources = db
.prepare(
`SELECT id, original_name, note FROM source_assets
WHERE product_id = ? AND role = 'product_reference' ORDER BY created_at DESC`
)
.all(productId) as unknown as Array<{ id: string; original_name: string; note: string | null }>;
if (rule.sourceMatch) {
for (const s of sources) {
if (rule.sourceMatch.test(`${s.original_name} ${s.note ?? ""}`)) ids.push(s.id);
}
}
for (const s of sources.slice(0, rule.fallbackSources)) ids.push(s.id);
return [...new Set(ids)].slice(0, 6);
}