919 lines
45 KiB
Python
919 lines
45 KiB
Python
"""Callback functions & State info for Sewing Pattern Configurator """
|
|
|
|
# NOTE: NiceGUI reference: https://nicegui.io/
|
|
|
|
import asyncio
|
|
import shutil
|
|
import traceback
|
|
from argparse import Namespace
|
|
# Async execution of regular functions
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import List, Tuple
|
|
from uuid import uuid4
|
|
|
|
import numpy as np
|
|
import yaml
|
|
from nicegui import ui, app, events
|
|
|
|
# Custom
|
|
from .gui_pattern import GUIPattern
|
|
|
|
icon_github = """
|
|
<svg viewbox="0 0 98 96" xmlns="http://www.w3.org/2000/svg">
|
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0
|
|
21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362
|
|
0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015
|
|
4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283
|
|
0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571
|
|
12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015
|
|
13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89
|
|
2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#fff"/>
|
|
</svg>
|
|
"""
|
|
icon_arxiv = """<svg id="primary_logo_-_single_color_-_white" data-name="primary logo - single color - white" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 246.978 110.119"><path d="M492.976,269.5l24.36-29.89c1.492-1.989,2.2-3.03,1.492-4.723a5.142,5.142,0,0,0-4.481-3.161h0a4.024,4.024,0,0,0-3.008,1.108L485.2,261.094Z" transform="translate(-358.165 -223.27)" fill="#fff"/><path d="M526.273,325.341,493.91,287.058l-.972,1.033-7.789-9.214-7.743-9.357-4.695,5.076a4.769,4.769,0,0,0,.015,6.53L520.512,332.2a3.913,3.913,0,0,0,3.137,1.192,4.394,4.394,0,0,0,4.027-2.818C528.4,328.844,527.6,327.133,526.273,325.341Z" transform="translate(-358.165 -223.27)" fill="#fff"/><path d="M479.215,288.087l6.052,6.485L458.714,322.7a2.98,2.98,0,0,1-2.275,1.194,3.449,3.449,0,0,1-3.241-2.144c-.513-1.231.166-3.15,1.122-4.168l.023-.024.021-.026,24.851-29.448m-.047-1.882-25.76,30.524c-1.286,1.372-2.084,3.777-1.365,5.5a4.705,4.705,0,0,0,4.4,2.914,4.191,4.191,0,0,0,3.161-1.563l27.382-29.007-7.814-8.372Z" transform="translate(-358.165 -223.27)" fill="#fff"/><path d="M427.571,255.154c1.859,0,3.1,1.24,3.985,3.453,1.062-2.213,2.568-3.453,4.694-3.453h14.878a4.062,4.062,0,0,1,4.074,4.074v7.828c0,2.656-1.327,4.074-4.074,4.074-2.656,0-4.074-1.418-4.074-4.074V263.3H436.515a2.411,2.411,0,0,0-2.656,2.745v27.188h10.007c2.658,0,4.074,1.329,4.074,4.074s-1.416,4.074-4.074,4.074h-26.39c-2.659,0-3.986-1.328-3.986-4.074s1.327-4.074,3.986-4.074h8.236V263.3h-7.263c-2.656,0-3.985-1.329-3.985-4.074,0-2.658,1.329-4.074,3.985-4.074Z" transform="translate(-358.165 -223.27)" fill="#fff"/><path d="M539.233,255.154c2.656,0,4.074,1.416,4.074,4.074v34.007h10.1c2.746,0,4.074,1.329,4.074,4.074s-1.328,4.074-4.074,4.074H524.8c-2.656,0-4.074-1.328-4.074-4.074s1.418-4.074,4.074-4.074h10.362V263.3h-8.533c-2.744,0-4.073-1.329-4.073-4.074,0-2.658,1.329-4.074,4.073-4.074Zm4.22-17.615a5.859,5.859,0,1,1-5.819-5.819A5.9,5.9,0,0,1,543.453,237.539Z" transform="translate(-358.165 -223.27)" fill="#fff"/><path d="M605.143,259.228a4.589,4.589,0,0,1-.267,1.594L590,298.9a3.722,3.722,0,0,1-3.721,2.48h-5.933a3.689,3.689,0,0,1-3.808-2.48l-15.055-38.081a3.23,3.23,0,0,1-.355-1.594,4.084,4.084,0,0,1,4.164-4.074,3.8,3.8,0,0,1,3.718,2.656l14.348,36.134,13.9-36.134a3.8,3.8,0,0,1,3.72-2.656A4.084,4.084,0,0,1,605.143,259.228Z" transform="translate(-358.165 -223.27)" fill="#fff"/><path d="M390.61,255.154c5.018,0,8.206,3.312,8.206,8.4v37.831H363.308a4.813,4.813,0,0,1-5.143-4.929V283.427a8.256,8.256,0,0,1,7-8.148l25.507-3.572v-8.4H362.306a4.014,4.014,0,0,1-4.141-4.074c0-2.87,2.143-4.074,4.355-4.074Zm.059,38.081V279.942l-24.354,3.4v9.9Z" transform="translate(-358.165 -223.27)" fill="#fff"/><path d="M448.538,224.52h.077c1,.024,2.236,1.245,2.589,1.669l.023.028.024.026,46.664,50.433a3.173,3.173,0,0,1-.034,4.336l-4.893,5.2-6.876-8.134L446.652,230.4c-1.508-2.166-1.617-2.836-1.191-3.858a3.353,3.353,0,0,1,3.077-2.02m0-1.25a4.606,4.606,0,0,0-4.231,2.789c-.705,1.692-.2,2.88,1.349,5.1l39.493,47.722,7.789,9.214,5.853-6.221a4.417,4.417,0,0,0,.042-6.042L452.169,225.4s-1.713-2.08-3.524-2.124Z" transform="translate(-358.165 -223.27)" fill="#fff"/></svg>"""
|
|
|
|
|
|
# # Dracula color theme
|
|
# theme_colors = Namespace(
|
|
# primary='#BD93F9', # Light purple (primary accent in Dracula theme)
|
|
# secondary='#FF79C6', # Pink
|
|
# accent='#FFB86C', # Orange
|
|
# dark='#282A36', # Dracula background (dark)
|
|
# positive='#50FA7B', # Green
|
|
# negative='#FF5555', # Red
|
|
# info='#8BE9FD', # Cyan (info color)
|
|
# warning='#F1FA8C' # Yellow
|
|
# )
|
|
|
|
theme_colors = Namespace(
|
|
primary='#5C5D71',
|
|
secondary='#333333',
|
|
accent='#a82c64',
|
|
dark='#4d1f48',
|
|
positive='#22ba38',
|
|
negative='#f50000',
|
|
info='#31CCEC',
|
|
warning='#9333ea'
|
|
)
|
|
|
|
|
|
messages: List[Tuple[str, str, str, str, str]] = []
|
|
|
|
|
|
@ui.refreshable
|
|
def chat_messages(own_id: str) -> None:
|
|
if messages:
|
|
for user_id, avatar, text, stamp, img_url in messages:
|
|
if img_url:
|
|
with ui.chat_message(text=text, stamp=stamp, avatar=avatar, sent=own_id==user_id).classes('w-full'):
|
|
ui.image(img_url).classes('w-64')
|
|
else:
|
|
ui.chat_message(text=text, stamp=stamp, avatar=avatar, sent=own_id==user_id).classes('w-full')
|
|
else:
|
|
ui.label('').classes('mx-auto my-36')
|
|
ui.run_javascript('window.scrollTo(0, document.body.scrollHeight)')
|
|
|
|
|
|
# State of GUI
|
|
class GUIState:
|
|
"""State of GUI-related objects
|
|
|
|
NOTE: "#" is used as a separator in GUI keys to avoid confusion with
|
|
symbols that can be (typically) used in body/design parameter names
|
|
('_', '-', etc.)
|
|
|
|
"""
|
|
def __init__(self) -> None:
|
|
self.window = None
|
|
|
|
# Pattern
|
|
self.pattern_state = GUIPattern()
|
|
|
|
# Pattern display constants
|
|
self.canvas_aspect_ratio = 1500. / 900 # Millimiter paper
|
|
self.w_rel_body_size = 0.5 # Body size as fraction of horisontal canvas axis
|
|
self.h_rel_body_size = 0.95
|
|
self.background_body_scale = 1 / 171.99 # Inverse of the mean_all body height from GGG
|
|
self.background_body_canvas_center = 0.273 # Fraction of the canvas (millimiter paper)
|
|
self.w_canvas_pad, self.h_canvas_pad = 0.011, 0.04
|
|
self.body_outline_classes = '' # Application of pattern&body scaling when it overflows
|
|
|
|
# Paths setup
|
|
# Static images for GUI
|
|
self.path_static_img = '/img'
|
|
app.add_static_files(self.path_static_img, './assets/img')
|
|
|
|
# 3D updates
|
|
self.path_static_3d = '/geo'
|
|
self.garm_3d_filename = f'garm_3d_{self.pattern_state.id}.glb'
|
|
self.local_path_3d = Path('./tmp_gui/garm_3d')
|
|
self.local_path_3d.mkdir(parents=True, exist_ok=True)
|
|
app.add_static_files(self.path_static_3d, self.local_path_3d)
|
|
app.add_static_files('/body', './assets/bodies')
|
|
#model api
|
|
self.baseurl = None
|
|
self.api = None
|
|
self.model = None
|
|
self.text_model = None
|
|
# Elements
|
|
self.ui_design_subtabs = {}
|
|
self.ui_pattern_display = None
|
|
self._async_executor = ThreadPoolExecutor(1)
|
|
|
|
self.pattern_state.reload_garment()
|
|
self.stylings()
|
|
self.layout()
|
|
|
|
def release(self):
|
|
"""Clean-up after the sesssion"""
|
|
self.pattern_state.release()
|
|
(self.local_path_3d / self.garm_3d_filename).unlink(missing_ok=True)
|
|
|
|
# Initial definitions
|
|
def stylings(self):
|
|
"""Theme definition"""
|
|
# Theme
|
|
# Here: https://quasar.dev/style/theme-builder
|
|
ui.colors(
|
|
primary=theme_colors.primary,
|
|
secondary=theme_colors.secondary,
|
|
accent=theme_colors.accent,
|
|
dark=theme_colors.dark,
|
|
positive=theme_colors.positive,
|
|
negative=theme_colors.negative,
|
|
info=theme_colors.info,
|
|
warning=theme_colors.warning
|
|
)
|
|
|
|
# SECTION Top level layout
|
|
def layout(self):
|
|
"""Overall page layout"""
|
|
|
|
# as % of viewport width/height
|
|
self.h_header = 5
|
|
self.h_params_content = 88
|
|
self.h_garment_display = 74
|
|
self.w_garment_display = 65
|
|
self.w_splitter_design = 32
|
|
self.scene_base_resoltion = (1024, 800)
|
|
|
|
# Helpers
|
|
self.def_pattern_waiting()
|
|
# TODOLOW One dialog for both?
|
|
self.def_design_file_dialog()
|
|
self.def_body_file_dialog()
|
|
|
|
# Configurator GUI
|
|
with ui.row(wrap=False).classes(f'w-full h-[{self.h_params_content}dvh] p-0 m-0 '):
|
|
# Tabs
|
|
self.def_param_tabs_layout()
|
|
|
|
# Pattern visual
|
|
self.view_tabs_layout()
|
|
|
|
# Overall wrapping
|
|
# NOTE: https://nicegui.io/documentation/section_pages_routing#page_layout
|
|
with ui.header(elevated=True, fixed=False).classes(f'h-[{self.h_header}vh] items-center justify-end py-0 px-4 m-0'):
|
|
ui.label('Design2GarmentCode').classes('mr-auto').style('font-size: 150%; font-weight: 400')
|
|
ui.button(
|
|
'Project',
|
|
on_click=lambda: ui.navigate.to('https://style3d.github.io/design2garmentcode/', new_tab=True)
|
|
).props('flat color=white')
|
|
with ui.link(target='http://arxiv.org/abs/2412.08603', new_tab=True):
|
|
ui.html(icon_arxiv).classes('w-16 bg-transparent')
|
|
with ui.link(target='https://github.com/Style3D/SXDGarmentCode', new_tab=True):
|
|
ui.html(icon_github).classes('w-8 bg-transparent')
|
|
# NOTE No ui.left_drawer(), no ui.right_drawer()
|
|
with ui.footer(fixed=False, elevated=True).classes('items-center justify-center p-0 m-0'):
|
|
# https://www.termsfeed.com/blog/sample-copyright-notices/
|
|
ui.link(
|
|
'© 2025 Style3D Research',
|
|
'https://www.linctex.com/',
|
|
new_tab=True
|
|
).classes('text-white')
|
|
|
|
def view_tabs_layout(self):
|
|
"""2D/3D view tabs"""
|
|
with ui.column(wrap=False).classes(f'h-[{self.h_params_content}vh] w-full items-center'):
|
|
with ui.tabs() as tabs:
|
|
self.ui_2d_tab = ui.tab('Sewing Pattern')
|
|
self.ui_3d_tab = ui.tab('3D view')
|
|
with ui.tab_panels(tabs, value=self.ui_2d_tab, animated=True).classes('w-full h-full items-center'):
|
|
with ui.tab_panel(self.ui_2d_tab).classes('w-full h-full items-center justify-center p-0 m-0'):
|
|
self.def_pattern_display()
|
|
with ui.tab_panel(self.ui_3d_tab).classes('w-full h-full items-center p-0 m-0'):
|
|
self.def_3d_scene()
|
|
|
|
ui.button('Download Current Garment', on_click=lambda: self.state_download()).classes('justify-self-end')
|
|
|
|
|
|
def def_param_tabs_layout(self):
|
|
"""Layout of tabs with parameters"""
|
|
with ui.column(wrap=False).classes(f'h-[{self.h_params_content}vh]'):
|
|
with ui.tabs().classes('w-full').props('dense') as tabs:
|
|
self.ui_lmm_tab = ui.tab('Parse Design').props('icon=chat').classes('text-sm')
|
|
self.ui_design_tab = ui.tab('Design Parameters').props('icon=checkroom').classes('text-sm')
|
|
self.ui_body_tab = ui.tab('Body Measurement').props('icon=accessibility_new').classes('text-sm')
|
|
|
|
with ui.tab_panels(tabs, value=self.ui_design_tab, animated=True).classes('w-full h-full items-center'):
|
|
with ui.tab_panel(self.ui_lmm_tab).classes('w-full h-full items-center p-0 m-0'):
|
|
self.def_lmm_tab()
|
|
with ui.tab_panel(self.ui_design_tab).classes('w-full h-full items-center p-0 m-0'):
|
|
self.def_design_tab()
|
|
with ui.tab_panel(self.ui_body_tab).classes('w-full h-full items-center p-0 m-0'):
|
|
self.def_body_tab()
|
|
|
|
|
|
def def_lmm_tab(self):
|
|
self.own_id = str(uuid4())
|
|
self.chat_msg = ''
|
|
self.chat_img_url = ''
|
|
|
|
def toggle_inputs():
|
|
if 'hidden' in self.api_input_fields.classes:
|
|
self.api_input_fields.classes.remove('hidden') # display
|
|
else:
|
|
self.api_input_fields.classes.append('hidden') # hidden
|
|
self.api_input_fields.update() # Update the UI
|
|
def submit_api_data():
|
|
self.baseurl = self.baseurl_input.value
|
|
self.api = self.api_input.value
|
|
self.model = self.model_input.value
|
|
self.text_model = self.text_model_input.value
|
|
toggle_inputs()
|
|
with ui.column().classes('w-full h-full'):
|
|
with ui.element('div').classes('w-full h-full overflow-auto'):
|
|
chat_messages(self.own_id)
|
|
|
|
with ui.row().classes('w-full mt-2'):
|
|
self.chat_input = ui.input(
|
|
placeholder='Describe your design or upload a reference image...').props('autofocus').classes('flex-grow').on('keydown.enter', self.handle_chat_input)
|
|
ui.button('Send', on_click=self.handle_chat_input).props('icon=send flat').classes('mt-2')
|
|
with ui.column().classes('hidden').style('margin-left: auto;') as self.api_input_fields:
|
|
self.baseurl_input = ui.input(placeholder='Base URL').classes('w-full')
|
|
self.api_input = ui.input(placeholder='API Key').classes('w-full')
|
|
self.model_input = ui.input(placeholder='Model Name').classes('w-full')
|
|
self.text_model_input = ui.input(placeholder='Text Model Name').classes('w-full')
|
|
ui.button('Submit', on_click=submit_api_data).classes('icon=send')
|
|
|
|
ui.button('open/close input baseurl api model', on_click=toggle_inputs).classes('icon=send')
|
|
|
|
with self.chat_input.add_slot('prepend'):
|
|
ui.icon('add_photo_alternate').on('click', self.open_image_upload_dialog).classes('cursor-pointer').style('margin-left: 8px;')
|
|
|
|
with ui.dialog() as self.image_upload_dialog:
|
|
with ui.card():
|
|
ui.upload(on_upload=self.handle_image_upload).props('accept="image/*"').classes('w-full')
|
|
ui.button('Close', on_click=self.image_upload_dialog.close).classes('mt-2')
|
|
|
|
|
|
async def handle_chat_input(self, e=None):
|
|
self.chat_msg = self.chat_input.value.strip()
|
|
|
|
if self.chat_msg:
|
|
timestamp = datetime.now().strftime('%H:%M')
|
|
messages.append((self.own_id, f'{self.path_static_img}/artist.png', self.chat_msg, timestamp, self.chat_img_url))
|
|
chat_messages.refresh(self.own_id)
|
|
|
|
await self.parse_design(self.chat_msg, self.chat_img_url,api_key=self.api, base_url=self.baseurl, model=self.model,text_model=self.text_model)
|
|
|
|
self.chat_input.value = ''
|
|
self.chat_img_url = ''
|
|
|
|
|
|
def open_image_upload_dialog(self):
|
|
self.image_upload_dialog.open()
|
|
|
|
|
|
async def handle_image_upload(self, e: events.UploadEventArguments):
|
|
image_name = e.name
|
|
image_path = f'{self.path_static_img}/{image_name}'
|
|
with open(f'./assets/img/{image_name}', 'wb') as f:
|
|
f.write(e.content.read())
|
|
|
|
self.chat_img_url = image_path
|
|
self.image_upload_dialog.close()
|
|
self.chat_msg = self.chat_input.value.strip()
|
|
|
|
timestamp = datetime.now().strftime('%H:%M')
|
|
messages.append((self.own_id, f'{self.path_static_img}/artist.png', self.chat_msg, timestamp, self.chat_img_url))
|
|
chat_messages.refresh(self.own_id)
|
|
await self.parse_design(self.chat_msg, f'./assets/img/{image_name}',api_key=self.api, base_url=self.baseurl, model=self.model,text_model=self.text_model)
|
|
|
|
self.chat_input.value = ''
|
|
self.chat_img_url = ''
|
|
|
|
|
|
def def_body_tab(self):
|
|
|
|
# Set of buttons
|
|
with ui.row():
|
|
ui.button('Upload', on_click=self.ui_body_dialog.open)
|
|
|
|
self.ui_active_body_refs = {}
|
|
self.ui_passive_body_refs = {}
|
|
with ui.scroll_area().classes('w-full h-full p-0 m-0'): # NOTE: p-0 m-0 gap-0 dont' seem to have effect
|
|
body = self.pattern_state.body_params
|
|
for param in body:
|
|
param_name = param.replace('_', ' ').capitalize()
|
|
elem = ui.number(
|
|
label=param_name,
|
|
value=str(body[param]),
|
|
format='%.2f',
|
|
precision=2,
|
|
step=0.5,
|
|
).classes('text-[0.85rem]')
|
|
|
|
if param[0] == '_': # Info elements for calculatable parameters
|
|
elem.disable()
|
|
self.ui_passive_body_refs[param] = elem
|
|
else: # active elements accepting input
|
|
# NOTE: e.sender == UI object, e.value == new value
|
|
elem.on_value_change(lambda e, dic=body, param=param: self.update_pattern_ui_state(
|
|
dic, param, e.value, body_param=True
|
|
))
|
|
self.ui_active_body_refs[param] = elem
|
|
|
|
def def_flat_design_subtab(self, ui_elems, design_params, use_collapsible=False):
|
|
"""Group of design parameters"""
|
|
for param in design_params:
|
|
param_name = param.replace('_', ' ').capitalize()
|
|
if 'v' not in design_params[param]:
|
|
ui_elems[param] = {}
|
|
if use_collapsible:
|
|
with ui.expansion().classes('w-full p-0 m-0') as expansion:
|
|
with expansion.add_slot('header'):
|
|
ui.label(f'{param_name}').classes('text-base self-center w-full h-full p-0 m-0')
|
|
with ui.row().classes('w-full h-full p-0 m-0'): # Ensures correct application of style classes for children
|
|
self.def_flat_design_subtab(ui_elems[param], design_params[param])
|
|
else:
|
|
with ui.card().classes('w-full shadow-md border m-0 rounded-md'):
|
|
ui.label(f'{param_name}').classes('text-base self-center w-full h-full p-0 m-0')
|
|
self.def_flat_design_subtab(ui_elems[param], design_params[param])
|
|
else:
|
|
# Leaf value
|
|
p_type = design_params[param]['type']
|
|
val = design_params[param]['v']
|
|
p_range = design_params[param]['range']
|
|
if 'select' in p_type:
|
|
values = design_params[param]['range']
|
|
if 'null' in p_type and None not in values:
|
|
values.append(None) # NOTE: Displayable value
|
|
ui.label(param_name).classes('p-0 m-0 mt-2 text-stone-500 text-[0.85rem]')
|
|
ui_elems[param] = ui.select(
|
|
values, value=val,
|
|
on_change=lambda e, dic=design_params, param=param: self.update_pattern_ui_state(dic, param, e.value)
|
|
).classes('w-full')
|
|
elif p_type == 'bool':
|
|
ui_elems[param] = ui.switch(
|
|
param_name, value=val,
|
|
on_change=lambda e, dic=design_params, param=param: self.update_pattern_ui_state(dic, param, e.value)
|
|
).classes('text-stone-500')
|
|
elif p_type == 'float' or p_type == 'int':
|
|
ui.label(param_name).classes('p-0 m-0 mt-2 text-stone-500 text-[0.85rem]')
|
|
ui_elems[param] = ui.slider(
|
|
value=val,
|
|
min=p_range[0],
|
|
max=p_range[1],
|
|
step=0.025 if p_type == 'float' else 1,
|
|
).props('snap label').classes('w-full') \
|
|
.on('update:model-value',
|
|
lambda e, dic=design_params, param=param: self.update_pattern_ui_state(dic, param, e.args),
|
|
throttle=0.5, leading_events=False)
|
|
|
|
# NOTE Events control: https://nicegui.io/documentation/slider#throttle_events_with_leading_and_trailing_options
|
|
elif 'file' in p_type:
|
|
print(f'GUI::NotImplementedERROR::{param}::'
|
|
'"file" parameter type is not yet supported in Web GarmentCode. '
|
|
'Creation of corresponding UI element skipped'
|
|
)
|
|
else:
|
|
print(f'GUI::WARNING::Unknown parameter type: {p_type}')
|
|
ui_elems[param] = ui.input(label=param_name, value=val, placeholder='Type the value',
|
|
validation={'Input too long': lambda value: len(value) < 20},
|
|
on_change=lambda e, dic=design_params, param=param: self.update_pattern_ui_state(dic, param, e.value)
|
|
).classes('w-full')
|
|
|
|
def def_design_tab(self):
|
|
# Set of buttons
|
|
with ui.row():
|
|
ui.button('Random', on_click=self.random)
|
|
ui.button('Default', on_click=self.default)
|
|
ui.button('Upload', on_click=self.ui_design_dialog.open)
|
|
|
|
# Design parameters
|
|
design_params = self.pattern_state.design_params
|
|
self.ui_design_refs = {}
|
|
if self.pattern_state.is_design_sectioned():
|
|
# Use tabs to represent top-level sections
|
|
with ui.splitter(value=self.w_splitter_design).classes('w-full h-full p-0 m-0') as splitter:
|
|
with splitter.before:
|
|
with ui.tabs().props('vertical').classes('w-full h-full') as tabs:
|
|
for param in design_params:
|
|
# Tab
|
|
self.ui_design_subtabs[param] = ui.tab(param)
|
|
self.ui_design_refs[param] = {}
|
|
|
|
with splitter.after:
|
|
with ui.tab_panels(tabs, value=self.ui_design_subtabs['meta']).props('vertical').classes('w-full h-full p-0 m-0'):
|
|
for param, tab_elem in self.ui_design_subtabs.items():
|
|
with ui.tab_panel(tab_elem).classes('w-full h-full p-0 m-0').style('gap: 0px'):
|
|
with ui.scroll_area().classes('w-full h-full p-0 m-0').style('gap: 0px'):
|
|
self.def_flat_design_subtab(
|
|
self.ui_design_refs[param],
|
|
design_params[param],
|
|
use_collapsible=(param == 'left')
|
|
)
|
|
else:
|
|
# Simplified display of designs
|
|
with ui.scroll_area().classes('w-full h-full p-0 m-0'):
|
|
self.def_flat_design_subtab(
|
|
self.ui_design_refs,
|
|
design_params,
|
|
use_collapsible=True
|
|
)
|
|
|
|
# !SECTION
|
|
# SECTION -- Pattern visuals
|
|
def def_pattern_display(self):
|
|
"""Prepare pattern display area"""
|
|
with ui.column().classes('h-full p-0 m-0'):
|
|
with ui.row().classes('w-full p-0 m-0 justify-between'):
|
|
switch = ui.switch(
|
|
'Body Silhouette', value=True,
|
|
).props('dense left-label').classes('text-stone-800')
|
|
|
|
self.ui_self_intersect = ui.label(
|
|
'WARNING: Garment panels are self-intersecting!'
|
|
).classes('font-semibold text-purple-600 border-purple-600 border py-0 px-1.5 rounded-md') \
|
|
.bind_visibility(self.pattern_state, 'is_self_intersecting')
|
|
|
|
with ui.image(
|
|
f'{self.path_static_img}/millimiter_paper_1500_900.png'
|
|
).classes(f'aspect-[{self.canvas_aspect_ratio}] h-[95%] p-0 m-0') as self.ui_pattern_bg:
|
|
# NOTE: Positioning: https://github.com/zauberzeug/nicegui/discussions/957
|
|
with ui.row().classes('w-full h-full p-0 m-0 bg-transparent relative top-[0%] left-[0%]'):
|
|
self.body_outline_classes = 'bg-transparent h-full absolute top-[0%] left-[0%] p-0 m-0'
|
|
try:
|
|
self.ui_body_outline = ui.image(f'{self.path_static_img}/ggg_outline_{self.pattern_state.body_id}.svg').classes(replace=self.body_outline_classes)
|
|
switch.bind_value(self.ui_body_outline, 'visible')
|
|
except Exception as e:
|
|
print('GUI::WARNING::Body silhouette not found, using mean_all', str(e))
|
|
self.ui_body_outline = ui.image(f'{self.path_static_img}/ggg_outline_mean_all.svg').classes(self.body_outline_classes)
|
|
switch.bind_value(self.ui_body_outline, 'visible')
|
|
|
|
# NOTE: ui.row allows for correct classes application (e.g. no padding on svg pattern)
|
|
with ui.row().classes('w-full h-full p-0 m-0 bg-transparent relative'):
|
|
# Automatically updates from source
|
|
self.ui_pattern_display = ui.interactive_image(
|
|
''
|
|
).classes('bg-transparent p-0 m-0')
|
|
|
|
# !SECTION
|
|
# SECTION 3D view
|
|
def create_lights(self, scene:ui.scene, intensity=30.0):
|
|
light_positions = np.array([
|
|
[1.60614, 1.23701, 1.5341,],
|
|
[1.31844, -2.52238, 1.92831],
|
|
[-2.80522, 2.34624, 1.2594],
|
|
[0.160261, 3.52215, 1.81789],
|
|
[-2.65752, -1.26328, 1.41194]
|
|
])
|
|
light_colors = [
|
|
'#ffffff',
|
|
'#ffffff',
|
|
'#ffffff',
|
|
'#ffffff',
|
|
'#ffffff'
|
|
]
|
|
z_dirs = np.arctan2(light_positions[:, 1], light_positions[:, 0])
|
|
|
|
# Add lights to the scene
|
|
for i in range(len(light_positions)):
|
|
scene.spot_light(
|
|
color=light_colors[i], intensity=intensity,
|
|
angle=np.pi,
|
|
).rotate(0., 0., -z_dirs[i]).move(light_positions[i][0], light_positions[i][1], light_positions[i][2])
|
|
|
|
def create_camera(self, cam_location, fov, scale=1.):
|
|
camera = ui.scene.perspective_camera(fov=fov)
|
|
camera.x = cam_location[0] * scale
|
|
camera.y = cam_location[1] * scale
|
|
camera.z = cam_location[2] * scale
|
|
|
|
# direction
|
|
camera.look_at_x = 0
|
|
camera.look_at_y = 0
|
|
camera.look_at_z = cam_location[2] * scale * 2/3
|
|
|
|
return camera
|
|
|
|
def def_3d_scene(self):
|
|
y_fov = 30 # Degrees == np.pi / 6. rad FOV
|
|
camera_location = [0, -4.15, 1.25]
|
|
bg_color='#ffffff'
|
|
|
|
def body_visibility(value):
|
|
self.ui_body_3d.visible(value)
|
|
|
|
with ui.row().classes('w-full p-0 m-0 justify-between items-center'):
|
|
self.ui_body_3d_switch = ui.switch(
|
|
'Body Silhouette',
|
|
value=True,
|
|
on_change=lambda e: body_visibility(e.value)
|
|
).props('dense left-label').classes('text-stone-800')
|
|
|
|
ui.button('Drape current design', on_click=lambda: self.update_3d_scene())
|
|
|
|
ui.label(
|
|
'INFO: it takes a few minutes'
|
|
).classes(f'font-semibold text-[{theme_colors.primary}] border-[{theme_colors.primary}] '
|
|
'border py-0 px-1.5 rounded-md')
|
|
|
|
camera = self.create_camera(camera_location, y_fov)
|
|
with ui.scene(
|
|
width=self.scene_base_resoltion[0],
|
|
height=self.scene_base_resoltion[1],
|
|
camera=camera,
|
|
grid=False,
|
|
background_color=bg_color
|
|
).classes(f'w-[{self.w_garment_display}vw] h-[90%] p-0 m-0') as self.ui_3d_scene:
|
|
# Lights setup
|
|
self.create_lights(self.ui_3d_scene, intensity=60.)
|
|
# NOTE: texture is there, just needs a better setup
|
|
self.ui_garment_3d = None
|
|
# TODOLOW Update body model to a correct shape
|
|
try:
|
|
self.ui_body_3d = self.ui_3d_scene.stl(
|
|
f'/body/{self.pattern_state.body_id}.stl'
|
|
).rotate(np.pi / 2, 0., 0.).material(color='#000000')
|
|
except Exception as e:
|
|
print(str(e))
|
|
self.ui_body_3d = self.ui_3d_scene.stl(
|
|
'/body/mean_all.stl'
|
|
).rotate(np.pi / 2, 0., 0.).material(color='#000000')
|
|
|
|
# !SECTION
|
|
# SECTION -- Other UI details
|
|
def def_pattern_waiting(self):
|
|
"""Define the waiting splashcreen with spinner
|
|
(e.g. waiting for a pattern to update)"""
|
|
|
|
# NOTE: the screen darkens because of the shadow
|
|
with ui.dialog(value=False).props(
|
|
'persistent maximized'
|
|
) as self.spin_dialog, ui.card().classes('bg-transparent'):
|
|
# Styles https://quasar.dev/vue-components/spinners
|
|
ui.spinner('hearts', size='15em').classes('fixed-center') # NOTE: 'dots' 'ball'
|
|
|
|
def def_body_file_dialog(self):
|
|
""" Dialog for loading parameter files (body)
|
|
"""
|
|
async def handle_upload(e: events.UploadEventArguments):
|
|
param_dict = yaml.safe_load(e.content.read())['body']
|
|
|
|
self.toggle_param_update_events(self.ui_active_body_refs)
|
|
|
|
self.pattern_state.body_id = e.name.split('.')[0]
|
|
self.pattern_state.set_new_body_params(param_dict)
|
|
self.update_body_params_ui_state(self.ui_active_body_refs)
|
|
await self.update_pattern_ui_state()
|
|
|
|
if self.ui_body_3d is not None: self.ui_body_3d.delete()
|
|
try:
|
|
print(f'INFO::Body silhouette update ggg_outline_{self.pattern_state.body_id}.svg')
|
|
self.ui_body_3d = self.ui_3d_scene.stl(
|
|
f'/body/{self.pattern_state.body_id}.stl'
|
|
).rotate(np.pi / 2, 0., 0.).material(color='#000000')
|
|
except Exception as e:
|
|
print('GUI::WARNING::3D Body mesh not found, using mean_all', str(e))
|
|
self.ui_body_3d = self.ui_3d_scene.stl(
|
|
'/body/mean_all.stl'
|
|
).rotate(np.pi / 2, 0., 0.).material(color='#000000')
|
|
|
|
try:
|
|
print(f'INFO::Body silhouette update ggg_outline_{self.pattern_state.body_id}.svg')
|
|
print('*** img_url: ', self.ui_body_outline.source)
|
|
self.ui_body_outline.set_source(f'{self.path_static_img}/ggg_outline_{self.pattern_state.body_id}.svg')
|
|
self.ui_body_outline.update()
|
|
except Exception as e:
|
|
print('GUI::WARNING::Body silhouette not found, using mean_all', str(e))
|
|
|
|
self.toggle_param_update_events(self.ui_active_body_refs)
|
|
|
|
ui.notify(f'Successfully applied {e.name}')
|
|
self.ui_body_dialog.close()
|
|
|
|
with ui.dialog() as self.ui_body_dialog, ui.card().classes('items-center'):
|
|
# NOTE: https://www.reddit.com/r/nicegui/comments/1393i2f/file_upload_with_restricted_types/
|
|
ui.upload(
|
|
label='Body parameters .yaml or .json',
|
|
on_upload=handle_upload
|
|
).classes('max-w-full').props('accept=".yaml,.json"')
|
|
|
|
ui.button('Close without upload', on_click=self.ui_body_dialog.close)
|
|
|
|
def def_design_file_dialog(self):
|
|
""" Dialog for loading parameter files (design)
|
|
"""
|
|
async def handle_upload(e: events.UploadEventArguments):
|
|
param_dict = yaml.safe_load(e.content.read())['design']
|
|
|
|
self.toggle_param_update_events(self.ui_design_refs) # Don't react to value updates
|
|
|
|
self.pattern_state.set_new_design(param_dict)
|
|
self.update_design_params_ui_state(self.ui_design_refs, self.pattern_state.design_params)
|
|
await self.update_pattern_ui_state()
|
|
|
|
self.toggle_param_update_events(self.ui_design_refs) # Re-enable reaction to value updates
|
|
|
|
ui.notify(f'Successfully applied {e.name}')
|
|
self.ui_design_dialog.close()
|
|
|
|
with ui.dialog() as self.ui_design_dialog, ui.card().classes('items-center'):
|
|
# NOTE: https://www.reddit.com/r/nicegui/comments/1393i2f/file_upload_with_restricted_types/
|
|
ui.upload(
|
|
label='Design parameters .yaml or .json',
|
|
on_upload=handle_upload
|
|
).classes('max-w-full').props('accept=".yaml,.json"')
|
|
|
|
ui.button('Close without upload', on_click=self.ui_design_dialog.close)
|
|
|
|
# !SECTION
|
|
# SECTION -- Event callbacks
|
|
async def update_pattern_ui_state(self, param_dict=None, param=None, new_value=None, body_param=False):
|
|
"""UI was updated -- update the state of the pattern parameters and visuals"""
|
|
# NOTE: Fixing to the "same value" issue in lambdas
|
|
# https://github.com/zauberzeug/nicegui/wiki/FAQs#why-have-all-my-elements-the-same-value
|
|
|
|
print('INFO::Updating pattern...')
|
|
|
|
# Update the values
|
|
if param_dict is not None:
|
|
if body_param:
|
|
param_dict[param] = new_value
|
|
else:
|
|
param_dict[param]['v'] = new_value
|
|
self.pattern_state.is_in_3D = False # Design param changes -> 3D model is not synced with the param
|
|
|
|
try:
|
|
if not self.pattern_state.is_slow_design():
|
|
# Quick update
|
|
self._sync_update_state()
|
|
return
|
|
|
|
# Display waiting spinner untill getting the result
|
|
# NOTE Splashscreen solution to block users from modifying params while updating
|
|
# https://github.com/zauberzeug/nicegui/discussions/1988
|
|
|
|
self.spin_dialog.open()
|
|
# NOTE: Using threads for async call
|
|
# https://stackoverflow.com/questions/49822552/python-asyncio-typeerror-object-dict-cant-be-used-in-await-expression
|
|
self.loop = asyncio.get_event_loop()
|
|
await self.loop.run_in_executor(self._async_executor, self._sync_update_state)
|
|
|
|
self.spin_dialog.close()
|
|
|
|
except KeyboardInterrupt as e:
|
|
raise e
|
|
except BaseException as e:
|
|
traceback.print_exc()
|
|
print(e)
|
|
self.spin_dialog.close() # If open
|
|
ui.notify(
|
|
'Failed to generate pattern correctly. Try different parameter values',
|
|
type='negative',
|
|
close_button=True,
|
|
position='center'
|
|
)
|
|
|
|
def _sync_update_state(self):
|
|
# Update derivative body values (just in case)
|
|
# TODOLOW only do that on body value updates
|
|
self.pattern_state.body_params.eval_dependencies()
|
|
self.update_body_params_ui_state(self.ui_passive_body_refs) # Display evaluated dependencies
|
|
|
|
# Update the garment
|
|
# Sync left-right for easier editing
|
|
self.pattern_state.sync_left(with_check=True)
|
|
|
|
# NOTE This is the slow part
|
|
self.pattern_state.reload_garment()
|
|
|
|
# TODOLOW the pattern is floating around when collars are added..
|
|
# Update display
|
|
if self.ui_pattern_display is not None:
|
|
|
|
if self.pattern_state.svg_filename:
|
|
# Re-align the canvas and body with the new pattern
|
|
p_bbox_size = self.pattern_state.svg_bbox_size
|
|
p_bbox = self.pattern_state.svg_bbox
|
|
|
|
# Margin calculations w.r.t. canvas size
|
|
# s.t. the pattern scales correctly
|
|
w_shift = abs(p_bbox[0]) # Body feet location in width direction w.r.t top-left corner of the pattern
|
|
m_top = (1. - abs(p_bbox[2]) * self.background_body_scale) * self.h_rel_body_size + (1. - self.h_rel_body_size) / 2
|
|
m_left = self.background_body_canvas_center - w_shift * self.background_body_scale * self.w_rel_body_size
|
|
m_right = 1 - m_left - p_bbox_size[0] * self.background_body_scale * self.w_rel_body_size
|
|
m_bottom = 1 - m_top - p_bbox_size[1] * self.background_body_scale * self.h_rel_body_size
|
|
|
|
# Canvas padding adjustment
|
|
m_top -= self.h_canvas_pad
|
|
m_left -= self.w_canvas_pad
|
|
m_right += self.w_canvas_pad # preserve evaluated width
|
|
m_bottom -= self.h_canvas_pad
|
|
|
|
# New placement
|
|
if m_top < 0 or m_bottom < 0 or m_left < 0 or m_right < 0:
|
|
# Calculate the fraction
|
|
scale_margin = 1.2
|
|
y_top_scale = abs(min(m_top * scale_margin, 0.)) + 1.
|
|
y_bot_scale = 1. + abs(min(m_bottom * scale_margin, 0.))
|
|
x_left_scale = abs(min(m_left * scale_margin, 0.)) + 1.
|
|
x_right_scale = abs(min(m_right * scale_margin, 0.)) + 1.
|
|
scale = min(1. / y_top_scale, 1. / y_bot_scale, 1. / x_left_scale, 1. / x_right_scale)
|
|
|
|
# Rescale the body
|
|
self.ui_body_outline.classes(
|
|
replace=self.body_outline_classes + f' origin-center scale-[{scale}]'
|
|
)
|
|
|
|
# Recalculate positioning & width
|
|
body_center = 0.5 - self.background_body_canvas_center
|
|
m_top = (1. - abs(p_bbox[2]) * self.background_body_scale) * self.h_rel_body_size * scale + (1. - self.h_rel_body_size * scale) / 2
|
|
m_left = (0.5 - body_center * scale) - w_shift * self.background_body_scale * self.w_rel_body_size * scale
|
|
m_right = 1 - m_left - p_bbox_size[0] * self.background_body_scale * self.w_rel_body_size * scale
|
|
|
|
# Canvas padding adjustment
|
|
# TODOLOW For some reason top adjustment is not needed here: m_top -= self.h_canvas_pad * scale
|
|
m_left -= self.w_canvas_pad * scale
|
|
m_right += self.w_canvas_pad * scale
|
|
|
|
else: # Display normally
|
|
# Remove body transforms if any were applied
|
|
self.ui_body_outline.classes(replace=self.body_outline_classes)
|
|
|
|
# New pattern image
|
|
self.ui_pattern_display.set_source(
|
|
str(self.pattern_state.svg_path()) if self.pattern_state.svg_filename else '')
|
|
self.ui_pattern_display.classes(
|
|
replace=f"""bg-transparent p-0 m-0
|
|
absolute
|
|
left-[{m_left * 100}%]
|
|
top-[{m_top * 100}%]
|
|
w-[{(1. - m_right - m_left) * 100}%]
|
|
height-auto
|
|
""")
|
|
|
|
else:
|
|
# Restore default state
|
|
self.ui_pattern_display.set_source('')
|
|
self.ui_body_outline.classes(replace=self.body_outline_classes)
|
|
|
|
def update_design_params_ui_state(self, ui_elems, design_params):
|
|
"""Sync ui params with the current state of the design params"""
|
|
for param in design_params:
|
|
if 'v' not in design_params[param]:
|
|
self.update_design_params_ui_state(ui_elems[param], design_params[param])
|
|
else:
|
|
ui_elems[param].value = design_params[param]['v']
|
|
|
|
def toggle_param_update_events(self, ui_elems):
|
|
"""Enable/disable event handling on the ui elements related to GarmentCode parameters"""
|
|
for param in ui_elems:
|
|
if isinstance(ui_elems[param], dict):
|
|
self.toggle_param_update_events(ui_elems[param])
|
|
else:
|
|
if ui_elems[param].is_ignoring_events: # -> disabled
|
|
ui_elems[param].enable()
|
|
else:
|
|
ui_elems[param].disable()
|
|
|
|
def update_body_params_ui_state(self, ui_body_refs):
|
|
"""Sync ui params with the current state of the body params"""
|
|
for param in ui_body_refs:
|
|
ui_body_refs[param].value = self.pattern_state.body_params[param]
|
|
|
|
|
|
async def update_3d_scene(self):
|
|
"""According the whatever pattern current state"""
|
|
|
|
print('INFO::Updating 3D...')
|
|
|
|
# Cleanup
|
|
if self.ui_garment_3d is not None:
|
|
self.ui_garment_3d.delete()
|
|
self.ui_garment_3d = None
|
|
|
|
if not self.pattern_state.svg_filename:
|
|
print('INFO::Current garment is empty, skipped 3D update')
|
|
ui.notify('Current garment is empty. Chose a design to start simulating!')
|
|
self.ui_body_3d.visible(True)
|
|
self.ui_body_3d_switch.set_value(True)
|
|
return
|
|
|
|
try:
|
|
# Display waiting spinner untill getting the result
|
|
# NOTE Splashscreen solution to block users from modifying params while updating
|
|
# https://github.com/zauberzeug/nicegui/discussions/1988
|
|
|
|
self.spin_dialog.open()
|
|
# NOTE: Using threads for async call
|
|
# https://stackoverflow.com/questions/49822552/python-asyncio-typeerror-object-dict-cant-be-used-in-await-expression
|
|
self.loop = asyncio.get_event_loop()
|
|
await self.loop.run_in_executor(self._async_executor, self._sync_update_3d)
|
|
|
|
# Update ui
|
|
# https://github.com/zauberzeug/nicegui/discussions/1269
|
|
with self.ui_3d_scene:
|
|
# NOTE: material is defined in the glb file
|
|
self.ui_garment_3d = self.ui_3d_scene.gltf(
|
|
f'geo/{self.garm_3d_filename}',
|
|
).scale(0.01).rotate(np.pi / 2, 0., 0.)
|
|
|
|
# Show the result! =)
|
|
self.spin_dialog.close()
|
|
|
|
except KeyboardInterrupt as e:
|
|
raise e
|
|
except BaseException as e:
|
|
traceback.print_exc()
|
|
print(e)
|
|
self.ui_3d_scene.set_visibility(True)
|
|
self.spin_dialog.close() # If open
|
|
ui.notify(
|
|
'Failed to generate 3D model correctly. Try different parameter values',
|
|
type='negative',
|
|
close_button=True,
|
|
position='center'
|
|
)
|
|
|
|
def _sync_update_3d(self):
|
|
"""Update 3d model"""
|
|
|
|
# Run simulation
|
|
path, filename = self.pattern_state.drape_3d()
|
|
|
|
# NOTE: The files will be available publically at the static point
|
|
# However, we cannot do much about it, since it won't be available for the interface otherwise
|
|
shutil.copy2(path / filename, self.local_path_3d / self.garm_3d_filename)
|
|
|
|
# Design buttons updates
|
|
async def design_sample(self):
|
|
"""Run design sampling"""
|
|
self.loop = asyncio.get_event_loop()
|
|
await self.loop.run_in_executor(self._async_executor, self.pattern_state.sample_design)
|
|
|
|
async def random(self):
|
|
# Sampling could be slow, so add spin always
|
|
self.spin_dialog.open()
|
|
|
|
self.toggle_param_update_events(self.ui_design_refs) # Don't react to value updates
|
|
|
|
await self.design_sample()
|
|
self.update_design_params_ui_state(self.ui_design_refs, self.pattern_state.design_params)
|
|
await self.update_pattern_ui_state()
|
|
|
|
self.toggle_param_update_events(self.ui_design_refs) # Re-do reaction to value updates
|
|
|
|
self.spin_dialog.close()
|
|
|
|
async def default(self):
|
|
self.toggle_param_update_events(self.ui_design_refs)
|
|
|
|
self.pattern_state.restore_design(False)
|
|
self.update_design_params_ui_state(self.ui_design_refs, self.pattern_state.design_params)
|
|
await self.update_pattern_ui_state()
|
|
|
|
self.toggle_param_update_events(self.ui_design_refs)
|
|
|
|
async def parse_design(self, text_prompt='', img_url='',api_key=None, base_url=None, model=None,text_model=None):
|
|
"""Parse design from text or image"""
|
|
|
|
def _sync_parse_design():
|
|
response = self.pattern_state.parse_chat(text_prompt, img_url,api_key, base_url, model,text_model)
|
|
return response
|
|
|
|
self.spin_dialog.open()
|
|
self.toggle_param_update_events(self.ui_design_refs)
|
|
|
|
self.loop = asyncio.get_event_loop()
|
|
response = await self.loop.run_in_executor(self._async_executor, _sync_parse_design)
|
|
|
|
# response = self.pattern_state.parse_chat(text_prompt, img_url)
|
|
print('GPT response: ', response)
|
|
|
|
self.update_design_params_ui_state(self.ui_design_refs, self.pattern_state.design_params)
|
|
await self.update_pattern_ui_state()
|
|
self.toggle_param_update_events(self.ui_design_refs)
|
|
self.spin_dialog.close()
|
|
|
|
# !SECTION
|
|
|
|
def state_download(self):
|
|
"""Download current state of a garment"""
|
|
archive_path = self.pattern_state.save()
|
|
ui.download(archive_path, f'Configured_design_{datetime.now().strftime("%y%m%d-%H-%M-%S")}.zip')
|