Parametryczny generator modeli 3D sterowany AI

Opublikowano 18 kwietnia 2026 · 6 min czytania
Dominik Szaradowski
Dominik Szaradowski
Full-Stack & AI Engineer
Kostka z otworem wygenerowana przez agenta AI w bluecad

Spędziłem miesiąc i sporo nieprzespanych nocy budując generator parametrycznych modeli 3D, w którym agent AI dostaje prompt typu „prosty regał na książki” i wypluwa gotowy plik STEP, GLB i FCStd. Działa. I nadal nie udało mi się wygenerować niczego, czego nie zrobię ręcznie we FreeCAD GUI w pięć minut.

To uczciwy raport z projektu chodzącego pod kodową nazwą bluecad. Docelowej nazwy jeszcze nie mam. Kodu na ten moment nie udostępniam, bo sam nie wiem, w którą stronę go pociągnę.

Co to miało robić

Pomysł był prosty. Piszesz w czacie „regał z pięcioma półkami, 180cm wysoki, 30cm głęboki”, agent rozumie geometrię, planuje feature’y tak, jak zrobiłby to inżynier w PartDesign (body → sketch → pad → pocket → fillet), a backend wypluwa parametryczny plik FCStd plus eksporty STEP, STL, GLB, OBJ, 3MF.

Stack jest dość konwencjonalny:

  • Monorepo na Turbo + pnpm
  • API w NestJS, BullMQ, Drizzle, autoryzacja przez Supabase
  • Worker w NestJS, konsumuje kolejkę zadań
  • FreeCAD jako osobny serwis Python (FastAPI), bo FreeCAD nie ma sensownego API w innym języku niż własny Python
  • Web w Nuxt 4 i Pinia
  • Postgres, Redis, Supabase Storage na pliki

Panel projektów w bluecad z listą wygenerowanych modeli 3D

Frontend (Nuxt 4) ma kolejkowy widok zadań, organizacje, projekty i osobny viewer 3D dla wygenerowanych GLB-ów.

Dlaczego FreeCAD

Zależało mi na prawdziwych BREP-ach, nie na meshach. Chciałem, żeby na końcu procesu inżynier mógł otworzyć wygenerowany plik w natywnym FreeCAD-zie, zobaczyć drzewo PartDesign, zmienić wartość pada, przesunąć szkic, dodać feature i recompute. Mesh by tego nie dał, STL z generatywki to ślepa uliczka.

To zawęziło listę. FreeCAD jest open source, działa headless, ma parametryczne body i klasyczny PartDesign znany z każdego komercyjnego CAD-a. Alternatywy odpadły szybko: OpenCascade bezpośrednio jest za niskopoziomowe i nie produkuje edytowalnego drzewa, Blender świetnie radzi sobie z meshami ale słabo z BREP-ami, a komercyjny stos typu OnShape czy Fusion ma zamknięte API i koszty licencji.

Wybór padł szybko. Później mocno tego żałowałem.

JSONCAD, czyli warstwa którą rozumie agent

Agent nie generuje kodu Pythona. Generuje warstwę pośrednią, którą nazwałem JSONCAD. To deklaratywny opis operacji, który mogę walidować, replayować, wersjonować, i co najważniejsze: który mogę podać LLM-owi w prostej formie.

Przykład „kostka z otworem”:

{
"operations": [
{ "type": "body", "id": "body1" },
{
"type": "sketch",
"id": "sketch1",
"parent": "body1",
"plane": { "position": [0, 0, 0], "rotation": [0, 0, 0] },
"entities": [{ "kind": "circle", "center": [0, 0], "radius": 5 }]
},
{
"type": "pad",
"id": "pad_0",
"parent": "body1",
"profile": "sketch1",
"extent": "distance",
"length": 20
}
]
}

Każda operacja ma własny zod schema i mapowanie na FreeCAD’owy newObject. Worker bierze JSONCAD, wywołuje serwis Pythona, ten odpala odpowiednie handlery i recompute() na dokumencie. Wszystko czego agent musi się trzymać to słownik kilkunastu typów operacji.

Dlaczego nie dałem agentowi JSON-a wprost

Pierwsza wersja wymuszała na agencie zwrot JSON-a. Trafiało średnio. LLM-y halucynują pola, mylą id z name, gubią pojedyncze nawiasy, czasem zaczynają emitować markdown wokół JSON-a. Walidator wywalał 30-40% odpowiedzi.

Przerzuciłem to na DSL przypominające TypeScript. Agent wypluwa coś takiego:

cad.body('body1')
cad.sketchPlane('sketch1', 0, 0, 0, 0, 0, 0)
cad.line(0, 0, 100, 0)
cad.line(100, 0, 100, 100)
cad.line(100, 100, 0, 100)
cad.line(0, 100, 0, 0)
cad.padDistance(10)
cad.sketchPlane('sketch2', 0, 0, 10, 0, 0, 0)
cad.circle(50, 50, 10)
cad.pocketDistance(10)
cad.filletAll(2)

Pod spodem to dalej JSONCAD, tylko budowany przez fluent builder. System prompt mówi wprost: pierwsza linia musi zaczynać się od cad., żadnych const, let, pętli ani warunków, jedna linia to jedno wywołanie metody. Walidacja DSL jest brutalnie prosta:

export function validateDsl(code: string): boolean {
for (const raw of code.split('\n')) {
const line = raw.trim();
if (!line) continue;
if (!line.startsWith('cad.')) return false;
if (!line.endsWith(')') && !line.endsWith(');')) return false;
if (/[{}=]/.test(line)) return false;
// method name has to be on the allowed list
}
return true;
}

Po tym ruchu success rate generacji przekroczył 90%. Ten sam prompt, ten sam Claude na backendzie, zmieniła się tylko forma odpowiedzi. Ograniczenie agenta dało lepszy wynik niż uczenie go formatu.

FreeCAD API to ból

Spodziewałem się, że to będzie kwestia kilku popołudni. Trwało tygodnie.

FreeCAD ma kilka warstw API, które różnie się ze sobą dogadują. Jest Part (niskopoziomowe geometrie), PartDesign (parametryczne feature’y), Sketcher (szkice 2D), Draft. Każda dokumentacja kieruje w inną stronę. To, co działa w Part, niekoniecznie działa po włożeniu do PartDesign::Body. Niektóre operacje wymagają recompute() w konkretnym momencie, inne psują dokument, jeśli recompute() zrobisz za wcześnie.

Klasyk: fillet. W GUI klikasz krawędzie. W API podajesz listę nazw typu Edge12, Edge17. Te nazwy zmieniają się przy każdej modyfikacji modelu, bo FreeCAD nie ma stabilnego TNP (Topological Naming Problem to znana, otwarta sprawa od lat). Skończyło się tak, że selektor krawędzi piszę po geometrii, nie po ID:

for i, edge in enumerate(shape.Edges):
v1, v2 = edge.Vertexes[0].Point, edge.Vertexes[1].Point
dx, dy, dz = v2.x - v1.x, v2.y - v1.y, v2.z - v1.z
if kind == "vertical":
if abs(dx) < tol and abs(dy) < tol and abs(dz) > tol:
edges_to_use.append(f"Edge{i+1}")
elif kind == "axis" and selector.axis == "z":
if abs(dx) < tol and abs(dy) < tol:
edges_to_use.append(f"Edge{i+1}")
# ...

Zamiast „weź krawędź numer X” agent mówi „weź wszystkie krawędzie pionowe” albo „wszystkie zewnętrzne”. Operuje pojęciami, nie identyfikatorami. To jedna z niewielu rzeczy, z których w tym projekcie jestem zadowolony.

Co udało się wygenerować

I tu zaczyna się część niezbyt zachęcająca.

Kostka z otworem, którą poprawiałem przez kilka wiadomości w czacie

Kostka z otworem. Wygląda spoko. Próbowałem zrobić „kostkę z dwiema przecinającymi się dziurami”. Po kilku turach agent twierdzi, że są dwie. Na modelu nadal jedna. Drugi pocket leciał po złej płaszczyźnie albo nie był rejestrowany jako część body.

Wygenerowany regał leżący na boku, z pionowymi przegrodami zamiast poziomych półek

Regał. Prompt: „simple bookshelf”. Lista feature’ów zgadza się z opisem, który wygenerował sam agent („rectangular frame, vertical side panels, five evenly spaced shelves”): rama jest, ściany są, pięć przegród też. Tyle że bryła leży na boku, jest szersza niż wyższa, a „półki” stoją pionowo zamiast poziomo. Wyszedł kasetnik na sztućce, nie regał na książki. Formalnie pasuje do specyfikacji, do niczego się nie nadaje.

Stolik na sześciu nogach z dziurami w blacie zamiast tylko otworów montażowych

Stolik. Prompt: „generate simple circle table with 6 legs”. Pierwsza próba: blat z sześcioma otworami, zero nóg. Po wiadomości „there isn’t legs” agent dorzucił nogi, ale otwory w blacie zostały. Coś co użytkownik nazwałby zwykłym stolikiem to tu nadal abstrakcja.

Trade-offy i co nie działa

Pełen wgląd w to, co poszło słabo:

Brak pętli zwrotnej. Agent generuje DSL, walidator sprawdza składnię, FreeCAD wykonuje. Nie ma żadnej weryfikacji typu „wyrenderuj, popatrz, popraw”. Bez tej pętli model nie wie, czy regał stoi pionowo, czy „półki” są ułożone poziomo, czy proporcje pasują do czegoś, na czym da się postawić książkę.

Brak pamięci geometrycznej. Agent nie wie, gdzie skończył pad poprzedniej operacji, więc kolejne szkice albo trafiają w przypadkowe płaszczyzny, albo bazują na uproszczonych założeniach (najczęściej XY na Z=0). Wszystkie modele wychodzą „płaskie” w sensie kompozycyjnym.

Topological Naming Problem. Selektor krawędzi po geometrii ratuje proste fillety, ale tylko dla prostych brył. Przy złożonej geometrii (loft, pipe, mirror) nie ma jak jednoznacznie odnieść się do krawędzi powstałej z dwóch wcześniejszych operacji.

FreeCAD headless lubi się wykrzaczyć. Pewne kombinacje booleanów + fillet wywalają cały proces FreeCAD-a, nie tylko zwracają błąd. Worker musi mieć health check i restart.

Token cost. Dłuższe prompty z pełnym opisem DSL i workflow constraints to 3-4k tokenów wejścia plus odpowiedź. Przy iteracji typu „popraw to” robi się drogo.

Dlaczego trzymam kod zamknięty

Na ten moment nie publikuję źródeł. Powody są dwa.

Po pierwsze, sam nie wiem, gdzie ten projekt jedzie. Mam na stole kilka kierunków: agent z domkniętą pętlą render-and-critic (multimodalny model patrzy na GLB i poprawia), generator pod wąską domenę (uchwyty do drukarek 3D, gdzie przestrzeń projektowa jest skończona), albo po prostu lepszy DSL z mocniejszymi prymitywami. Każda z tych ścieżek wymaga innej architektury. Otwieranie kodu, który zaraz może się obrócić o 180 stopni, nie ma sensu.

Po drugie, w obecnym stanie kod jest raczej dowodem, że problem jest trudniejszy niż wyglądał, niż gotowym narzędziem. Wolę nie zachęcać do forkowania czegoś, co nie umie zrobić stolika z sześcioma nogami.

Co bym zrobił inaczej

Rozważyłbym CadQuery albo build123d zamiast FreeCAD-a. Oba też siedzą na OpenCascade i produkują uczciwe BREP-y do STEP-a, tylko bez warstwy obiektowej FreeCAD-a. Trade-off jest jednak realny: tracę FCStd z drzewem PartDesign do edycji w GUI. STEP da się otworzyć w FreeCAD-zie, ale jako pojedynczą bryłę, nie listę feature’ów do tweakowania. Pytanie czy ta edytowalność jest faktycznie istotna dla użytkownika, czy tylko ładnie wygląda na slajdzie.

Zacząłbym od pętli krytyk od dnia pierwszego. Wynik renderowany do PNG, multimodalny model porównuje z promptem, kolejna iteracja. Bez tego agent gada w ciemno.

I poszedłbym w wąską domenę, nie ogólny CAD. Generator „dowolnego modelu 3D” to nie jest jedna zima pracy. Generator uchwytów do GoPro to dwa tygodnie roboty i działa.

Kiedy to ma sens

Pomysł „LLM jako interfejs do CAD-a” ma sens, ale prawdopodobnie nie w formie, w której zacząłem. W tej chwili sensownie wygląda to dla:

  • bardzo zamkniętych domen (parametryczne komponenty, uchwyty, mocowania),
  • generowania bezpośrednio kodu CadQuery, nie warstwy abstrakcji nad nim,
  • toolingu typu „autouzupełnianie szkicu” obok inżyniera, nie zamiast.

Wracam do tego projektu, ale prawdopodobnie z innym backendem i znacznie węższym scope. Jak będzie docelowa nazwa, dam znać.