""" ui.py ----- All Blender UI classes for knot_animation: KNOT_UL_List — playlist UIList KNOT_UL_AllowedMaterialsList— generator filter UIList draw_knot_properties() — shared property layout helper (collapsible sections) KNOT_PT_Panel — main N-panel (VIEW_3D > UI > AnimKnots) Every major block is collapsible via per-item or per-settings ui_show_* flags. KnotItem flags are per-item so each playlist entry remembers its own state. """ from __future__ import annotations import bpy from .handler import compute_playlist_duration # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _section(layout, item, flag: str, label: str, icon: str = 'NONE'): """Draw a collapsible box section. Returns (box, is_open).""" box = layout.box() row = box.row() row.prop(item, flag, icon='TRIA_DOWN' if getattr(item, flag) else 'TRIA_RIGHT', icon_only=True, emboss=False) row.label(text=label, icon=icon) return box, getattr(item, flag) def _subsection(layout, item, flag: str, label: str, icon: str = 'NONE'): """Like _section but without an outer box — for nesting inside existing boxes.""" row = layout.row() row.prop(item, flag, icon='TRIA_DOWN' if getattr(item, flag) else 'TRIA_RIGHT', icon_only=True, emboss=False) row.label(text=label, icon=icon) return getattr(item, flag) def draw_prop_with_mod(layout, item, prop: str, mod_prop: str): """Draw a split row with the base property and its attenuverter (Mod).""" split = layout.split(factor=0.6) split.prop(item, prop) split.prop(item, mod_prop) # --------------------------------------------------------------------------- # UILists # --------------------------------------------------------------------------- class KNOT_UL_List(bpy.types.UIList): def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index): layout.label(text=item.name, icon='CURVE_PATH') class KNOT_UL_AllowedMaterialsList(bpy.types.UIList): def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index): row = layout.row(align=True) row.prop(item, "enabled", text="") if item.is_preset: row.label(text=f"Preset: {item.name}", icon='SHADING_RENDERED') else: row.label(text=f"Material: {item.name}", icon='MATERIAL') # --------------------------------------------------------------------------- # Shared property layout helper # --------------------------------------------------------------------------- def draw_knot_properties(layout, item) -> None: """Draw all KnotItem properties using collapsible per-item sections.""" # ── Shape ────────────────────────────────────────────────────────────── box, open_ = _section(layout, item, "ui_show_shape", "Shape", 'CURVE_PATH') if open_: box.prop(item, "shape_type") if item.shape_type == 'TORUS_KNOT': draw_prop_with_mod(box, item, "torus_p", "mod_torus_p") draw_prop_with_mod(box, item, "torus_q", "mod_torus_q") row = box.row(align=True) row.prop(item, "flip_p", toggle=True, icon='ARROW_LEFTRIGHT') row.prop(item, "flip_q", toggle=True, icon='ARROW_LEFTRIGHT') box.prop(item, "mode") if item.mode == 'MAJOR_MINOR': draw_prop_with_mod(box, item, "torus_R", "mod_torus_R") draw_prop_with_mod(box, item, "torus_r", "mod_torus_r") else: draw_prop_with_mod(box, item, "torus_eR", "mod_torus_eR") draw_prop_with_mod(box, item, "torus_iR", "mod_torus_iR") elif item.shape_type == 'MOBIUS': draw_prop_with_mod(box, item, "mobius_twists", "mod_mobius_twists") draw_prop_with_mod(box, item, "mobius_width", "mod_mobius_width") elif item.shape_type == 'LISSAJOUS': draw_prop_with_mod(box, item, "liss_kx", "mod_liss_kx") draw_prop_with_mod(box, item, "liss_ky", "mod_liss_ky") draw_prop_with_mod(box, item, "liss_kz", "mod_liss_kz") draw_prop_with_mod(box, item, "liss_amp", "mod_liss_amp") elif item.shape_type == 'SPIRAL': draw_prop_with_mod(box, item, "spiral_turns", "mod_spiral_turns") draw_prop_with_mod(box, item, "spiral_R", "mod_spiral_R") # ── Geometry ──────────────────────────────────────────────────────────── box, open_ = _section(layout, item, "ui_show_geometry", "Geometry", 'MESH_DATA') if open_: draw_prop_with_mod(box, item, "geo_extrude", "mod_geo_extrude") draw_prop_with_mod(box, item, "geo_offset", "mod_geo_offset") draw_prop_with_mod(box, item, "geo_bDepth", "mod_geo_bDepth") draw_prop_with_mod(box, item, "torus_h", "mod_torus_h") # ── Links & Phases ─────────────────────────────────────────────────────── box, open_ = _section(layout, item, "ui_show_links", "Links & Phases", 'LINKED') if open_: box.prop(item, "multiple_links") col = box.column(align=True) row = col.row(align=True) row.prop(item, "torus_u") row.prop(item, "torus_v") row = col.row(align=True) row.prop(item, "torus_rP") row.prop(item, "torus_sP") # ── Animation Rates ────────────────────────────────────────────────────── box, open_ = _section(layout, item, "ui_show_anim", "Animation Rates", 'ANIM') if open_: box.prop(item, "cycle_rate") col = box.column(align=True) row = col.row(align=True) row.prop(item, "spin_phase_rate") row.prop(item, "rev_phase_rate") row = col.row(align=True) row.prop(item, "height_rate") row = col.row(align=True) row.prop(item, "scale_rate") row.prop(item, "scale_amplitude") # ── Material ───────────────────────────────────────────────────────────── box, open_ = _section(layout, item, "ui_show_material", "Material", 'MATERIAL') if open_: box.prop(item, "material_mode", expand=True) if item.material_mode == 'PRESET': box.prop(item, "shader_id") col = box.column(align=True) col.prop(item, "preset_color") row = col.row(align=True) row.prop(item, "preset_roughness", slider=True) row.prop(item, "preset_metallic", slider=True) col.prop(item, "preset_emission_strength") else: box.prop(item, "project_material", text="Material") # ── Colors ─────────────────────────────────────────────────────────────── box, open_ = _section(layout, item, "ui_show_colors", "Colors", 'COLOR') if open_: box.prop(item, "use_colors") if item.use_colors: row = box.row(align=True) row.prop(item, "colorSet") row.prop(item, "random_colors") # ── Transition ─────────────────────────────────────────────────────────── box, open_ = _section(layout, item, "ui_show_trans", "Transition", 'MOD_TIME') if open_: box.prop(item, "transition_frames") if item.transition_frames > 0: box.prop(item, "transition_easing") # --------------------------------------------------------------------------- # Main panel # --------------------------------------------------------------------------- class KNOT_PT_Panel(bpy.types.Panel): bl_label = "AnimKnots" bl_idname = "KNOT_PT_panel" bl_space_type = 'VIEW_3D' bl_region_type = 'UI' bl_category = 'AnimKnots' def draw(self, context): layout = self.layout scene = context.scene glob = scene.knot_globals gen = scene.knot_generator # ── Playlist ───────────────────────────────────────────────────────── pl_box = layout.box() pl_row = pl_box.row() pl_row.prop(glob, "ui_show_playlist", icon='TRIA_DOWN' if glob.ui_show_playlist else 'TRIA_RIGHT', icon_only=True, emboss=False) pl_row.label(text="Playlist", icon='NLA') if glob.ui_show_playlist: row = pl_box.row() row.template_list("KNOT_UL_List", "", scene, "knot_list", scene, "knot_list_index") col = row.column(align=True) col.operator("knot.add_item", text="", icon='ADD') col.operator("knot.remove_item", text="", icon='REMOVE') col.separator() col.operator("knot.move_item", text="", icon='TRIA_UP').direction = 'UP' col.operator("knot.move_item", text="", icon='TRIA_DOWN').direction = 'DOWN' pl_box.operator("knot.populate", icon='FILE_REFRESH') # ── Selected Knot ───────────────────────────────────────────────────── if 0 <= scene.knot_list_index < len(scene.knot_list): item = scene.knot_list[scene.knot_list_index] ke_box = layout.box() ke_row = ke_box.row() ke_row.prop(glob, "ui_show_knot_edit", icon='TRIA_DOWN' if glob.ui_show_knot_edit else 'TRIA_RIGHT', icon_only=True, emboss=False) ke_row.label(text=f"Knot: {item.name}", icon='CURVE_PATH') if glob.ui_show_knot_edit: ke_box.prop(item, "name", text="") draw_knot_properties(ke_box, item) ke_box.operator("knot.bake_preview", icon='FILE_TICK') # ── Global Settings ─────────────────────────────────────────────────── gb = layout.box() gb_row = gb.row() gb_row.prop(glob, "ui_show_global", icon='TRIA_DOWN' if glob.ui_show_global else 'TRIA_RIGHT', icon_only=True, emboss=False) gb_row.label(text="Global Settings", icon='WORLD') if glob.ui_show_global: gb.prop(glob, "knot_scale") gb.prop(glob, "frames_per_knot") # Resolutions res_box = gb.box() res_box.label(text="Resolutions (Preview / Render)", icon='RESTRICT_VIEW_OFF') row = res_box.row(align=True) row.prop(glob, "preview_resolution", text="Curve") row.prop(glob, "render_resolution", text="") row = res_box.row(align=True) row.prop(glob, "preview_bevel_resolution", text="Bevel") row.prop(glob, "render_bevel_resolution", text="") # Visual Effects & Mods fx_box = gb.box() fx_box.label(text="Visual Effects & Tweaks", icon='MODIFIER') fx_box.prop(glob, "global_master_thickness", slider=True) fx_box.prop(glob, "global_emission_multiplier", slider=True) fx_box.prop(glob, "global_hue_shift", slider=True) fx_box.prop(glob, "global_turbulence", slider=True) fx_box.prop(glob, "camera_shake_amplitude", slider=True) fx_box.prop(glob, "auto_turntable_speed") row = fx_box.row(align=True) row.prop(glob, "smooth_shading", toggle=True, icon='SHADING_RENDERED') row.prop(glob, "viewport_wireframe", toggle=True, icon='SHADING_WIRE') n_knots = len(scene.knot_list) if n_knots > 0: total_frames = compute_playlist_duration(scene) fps = max(1, scene.render.fps) secs = total_frames / fps gb.label( text=f"Playlist: {total_frames} fr / {secs:.1f}s ({n_knots} knots)", icon='TIME') row = gb.row(align=True) row.operator("knot.fit_timeline", icon='PREVIEW_RANGE', text="Fit Timeline") row.operator("knot.fit_playlist", icon='NLA_PUSHDOWN', text="Fit Playlist") # · Playback sub-section · gb.separator() pb_open = _subsection(gb, glob, "ui_show_playback", "Playback", 'PLAY') if pb_open: pb = gb.column(align=True) pb.prop(glob, "global_speed") pb.prop(glob, "animation_phase") pb.prop(glob, "reactivity_factor", slider=True) gb.label(text="↑ Keyframe or drive for reactivity", icon='INFO') row = gb.row(align=True) row.prop(scene, "frame_end", text="Total Frames") row.prop(scene.render, "fps", text="FPS") # ── Random Generator ────────────────────────────────────────────────── gen_box = layout.box() gen_hdr = gen_box.row() gen_hdr.prop(gen, "ui_show_generator", icon='TRIA_DOWN' if gen.ui_show_generator else 'TRIA_RIGHT', icon_only=True, emboss=False) gen_hdr.label(text="Random Generator", icon='GROUP') if gen.ui_show_generator: gen_box.prop(gen, "num_knots") # · Base Knot Defaults · base_box = gen_box.box() base_row = base_box.row() base_row.prop(gen, "ui_show_base", icon='TRIA_DOWN' if gen.ui_show_base else 'TRIA_RIGHT', icon_only=True, emboss=False) base_row.label(text="Base Defaults", icon='PRESET') if gen.ui_show_base: draw_knot_properties(base_box, gen.base_knot) # · Randomise Toggles (collapsible container) · rand_box = gen_box.box() rand_hdr = rand_box.row() rand_hdr.prop(gen, "ui_show_rand_toggles", icon='TRIA_DOWN' if gen.ui_show_rand_toggles else 'TRIA_RIGHT', icon_only=True, emboss=False) rand_hdr.label(text="Randomise Toggles", icon='MODIFIER') if gen.ui_show_rand_toggles: def draw_rand(prop): row = rand_box.row(align=True) row.prop(gen, f"r_{prop}") if getattr(gen, f"r_{prop}"): row.prop(gen, f"min_{prop}") row.prop(gen, f"max_{prop}") def draw_rand_bool(prop): row = rand_box.row(align=True) row.prop(gen, f"r_{prop}") if getattr(gen, f"r_{prop}") and hasattr(gen, f"prob_{prop}"): row.prop(gen, f"prob_{prop}") # ·· Shape ·· s_box = rand_box.box() s_row = s_box.row() s_row.prop(gen, "ui_show_rand_shape", icon='TRIA_DOWN' if gen.ui_show_rand_shape else 'TRIA_RIGHT', icon_only=True, emboss=False) s_row.label(text="Shape", icon='CURVE_PATH') if gen.ui_show_rand_shape: s_box.prop(gen, "r_shape_type") draw_rand("torus_p"); draw_rand("torus_q") draw_rand_bool("flip_p"); draw_rand_bool("flip_q") draw_rand_bool("mode") draw_rand("torus_R"); draw_rand("torus_r") draw_rand("torus_eR"); draw_rand("torus_iR") draw_rand("mobius_twists"); draw_rand("mobius_width") draw_rand("liss_kx"); draw_rand("liss_ky") draw_rand("liss_kz"); draw_rand("liss_amp") draw_rand("spiral_turns"); draw_rand("spiral_R") # ·· Geometry & Links ·· g_box = rand_box.box() g_row = g_box.row() g_row.prop(gen, "ui_show_rand_geo", icon='TRIA_DOWN' if gen.ui_show_rand_geo else 'TRIA_RIGHT', icon_only=True, emboss=False) g_row.label(text="Geometry & Links", icon='MESH_DATA') if gen.ui_show_rand_geo: draw_rand("geo_extrude"); draw_rand("geo_offset") draw_rand("geo_bDepth"); draw_rand("torus_h") draw_rand_bool("multiple_links") draw_rand("torus_u"); draw_rand("torus_v") draw_rand("torus_rP"); draw_rand("torus_sP") draw_rand("transition_frames") draw_rand_bool("transition_easing") # ·· Animation Rates ·· a_box = rand_box.box() a_row = a_box.row() a_row.prop(gen, "ui_show_rand_anim", icon='TRIA_DOWN' if gen.ui_show_rand_anim else 'TRIA_RIGHT', icon_only=True, emboss=False) a_row.label(text="Animation Rates", icon='ANIM') if gen.ui_show_rand_anim: draw_rand("cycle_rate") draw_rand("spin_phase_rate"); draw_rand("rev_phase_rate") draw_rand("height_rate") draw_rand("scale_rate"); draw_rand("scale_amplitude") # ·· Material ·· m_box = rand_box.box() m_row = m_box.row() m_row.prop(gen, "ui_show_rand_mat", icon='TRIA_DOWN' if gen.ui_show_rand_mat else 'TRIA_RIGHT', icon_only=True, emboss=False) m_row.label(text="Material", icon='MATERIAL') if gen.ui_show_rand_mat: row = m_box.row(align=True) row.prop(gen, "r_material") if gen.r_material: row.prop(gen, "r_preset_params", text="Rnd Params") filter_box = m_box.box() filter_box.label(text="Allowed Materials & Presets:") row_sync = filter_box.row() row_sync.template_list( "KNOT_UL_AllowedMaterialsList", "", gen, "allowed_materials", gen, "allowed_materials_index", rows=5) row_sync.column(align=True).operator( "knot.sync_generator_materials", icon='FILE_REFRESH', text="") if len(gen.allowed_materials) == 0: filter_box.label( text="Click Sync to load project materials & presets", icon='INFO') gen_box.operator("knot.generate_random", icon='PLAY') # ── Export ──────────────────────────────────────────────────────────── ex_box = layout.box() ex_box.label(text="Export", icon='EXPORT') ex_box.label(text="Bake all frames to a standalone .blend file", icon='INFO') row = ex_box.row() row.scale_y = 1.4 row.operator("knot.bake_export", icon='RENDER_ANIMATION')