/** * 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 ` ${ctx.productName.toUpperCase()} — RYSUNEK WYMIAROWY ${ctx.fabricLabel} · wszystkie wymiary w centymetrach · ${datePl(ctx.date)} ${topView} ${frontView} ${sleepView} ${titleBlock} VILMAX · rysunek wymiarowy `; } /** * 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 ` ${seps}${cells} `; } // ================= 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 ` ${ctx.productName.toUpperCase()} — RYSUNEK WYMIAROWY ${ctx.fabricLabel} · wszystkie wymiary w centymetrach · ${datePl(ctx.date)} ${views .map((v) => { const b = layout.boxes[v]!; return title(b.x, b.y - 18, BLUEPRINT_VIEW_TITLES[v]); }) .join("")} ${title(tbX, tbY - 26, "DANE RYSUNKU")} ${tbRows} Ilustracja poglądowa — nie zastępuje rysunku technicznego producenta. VILMAX · rysunek wymiarowy `; } /** * 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 ` ${parts.join("\n")} `; } /** 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 ` ${ctx.productName.toUpperCase()} — RYSUNEK WYMIAROWY ${ctx.fabricLabel} · wszystkie wymiary w centymetrach · ${datePl(ctx.date)} ${title(tbX, tbY - 26, "DANE RYSUNKU")} ${tbRows} Ilustracja poglądowa — nie zastępuje rysunku technicznego producenta. VILMAX · rysunek wymiarowy `; } /** 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); }