diff --git a/hipsterfy/hipsterfy.py b/hipsterfy/hipsterfy.py index 11b919a..c4700e8 100644 --- a/hipsterfy/hipsterfy.py +++ b/hipsterfy/hipsterfy.py @@ -13,6 +13,13 @@ from typing import List, Dict, Any import pandas as pd import re from enum import Enum +from solid import * +from solid.utils import * +import numpy as np +import trimesh +import cairosvg + + MAX_ALBUM_IMG_SIZE = 512 @@ -238,6 +245,165 @@ class HipsterfyPlaylistItem(object): """ + def create_text_mesh_svg(self, text, font_size=100, extrude_height=1.0, font_family="Arial"): + # Define a rough bounding box size (oversized is OK, it will crop to path later) + svg_width = font_size * len(text) * 0.6 # heuristic width + svg_height = font_size * 1.5 # little padding below + + # 1. Create SVG with size attributes! + svg_template = f''' + + {text} + ''' + + # 2. Use CairoSVG to convert to SVG with outlines (in-memory) + outlined_svg = BytesIO() + cairosvg.svg2svg(bytestring=svg_template.encode('utf-8'), write_to=outlined_svg) + outlined_svg.seek(0) + + # 3. Load the SVG Path into Trimesh + path = trimesh.load_path(outlined_svg, "svg") + + # 4. Get closed polygons + polygons = path.polygons_full + if len(polygons) == 0: + raise ValueError("No polygons found in SVG. Make sure the font is available.") + + # 5. Extrude into 3D meshes + meshes = [] + for p in polygons: + mesh = trimesh.creation.extrude_polygon(p, height=extrude_height) + # Fix potential non-watertight edges + mesh = mesh.copy().process(validate=True) + if not mesh.is_watertight: + print("Warning: Mesh is not watertight. Trying to fix...") + mesh = mesh.convex_hull # Hack: force watertight by convex hull + meshes.append(mesh) + + text_mesh = trimesh.util.concatenate(meshes) + text_mesh.apply_translation(-text_mesh.centroid) + + # Final safety net + if not text_mesh.is_watertight: + print("Final Warning: Text mesh still not watertight after fix.") + + return text_mesh + + def generate_3d_qr_card( + self, + primary_property, + secondary_property, + additional_properties: List[str] = None, + card_size=60, + card_thickness=3, + qr_relief_depth=0.5, # Tiefe der Vertiefung + qr_margin=8 + ): + """ + Generates a 3D card (STL as bytes) with a sunken QR code (engraved). + Returns: STL file as bytes. + """ + qr = qrcode.QRCode(box_size=1, border=0) + qr.add_data(self.link) + qr.make(fit=True) + img = qr.make_image(fill_color="black", back_color="white").convert("1") + arr = np.array(img) + h, w = arr.shape + pixel_size = (card_size - 2 * qr_margin) / w + + # Grundplatte + base = trimesh.creation.box(extents=(card_size, card_size, card_thickness)) + base.apply_translation((card_size/2, card_size/2, card_thickness/2)) + + # QR-Vertiefung (nur die weißen Felder bleiben stehen, schwarze werden "eingesunken") + cutouts_xa_ya = [] + cutouts_xa_yb = [] + cutouts_xb_ya = [] + cutouts_xb_yb = [] + for y in range(h): + for x in range(w): + if arr[y, x] == 0: # Schwarzes Pixel → wird vertieft + px = qr_margin + x * pixel_size + pixel_size/2 + py = qr_margin + (h - y - 1) * pixel_size + pixel_size/2 + cube = trimesh.creation.box(extents=(pixel_size * 1.00001, pixel_size * 1.00001, qr_relief_depth)) + cube.apply_translation((px, py, card_thickness - qr_relief_depth/2)) + if y % 2 == 0: + if x % 2 == 0: + cutouts_xa_ya.append(cube) + else: + cutouts_xa_yb.append(cube) + else: + if x % 2 == 0: + cutouts_xb_ya.append(cube) + else: + cutouts_xb_yb.append(cube) + card = base + + if cutouts_xa_ya: + cutout_xa_ya = trimesh.util.concatenate(cutouts_xa_ya) + card = card.difference(cutout_xa_ya) + if cutouts_xa_yb: + cutout_xa_yb = trimesh.util.concatenate(cutouts_xa_yb) + card = card.difference(cutout_xa_yb) + if cutouts_xb_ya: + cutout_xb_ya = trimesh.util.concatenate(cutouts_xb_ya) + card = card.difference(cutout_xb_ya) + if cutouts_xb_yb: + cutout_xb_yb = trimesh.util.concatenate(cutouts_xb_yb) + card = card.difference(cutout_xb_yb) + + # engrave the text to the lower card side + lines = [] + if primary_property: + lines.append(str(getattr(self, primary_property, ""))) + if secondary_property: + lines.append(str(getattr(self, secondary_property, ""))) + if additional_properties: + for prop in additional_properties: + value = getattr(self, prop, "") + if value: + lines.append(f"{prop.replace('_', ' ').title()}: {value}") + + text_depth = qr_relief_depth + text_size = card_size * 0.09 # Schriftgröße relativ zur Karte + # Combine into one mesh + text_mesh = self.create_text_mesh_svg("\n".join(lines), font_size=text_size, extrude_height=text_depth, font_family="Arial") + # Center the text mesh + text_mesh.apply_translation((-text_mesh.centroid[0], -text_mesh.centroid[1], 0)) + # Position the text mesh on the card + text_mesh.apply_translation((card_size / 2, card_size / 2, card_thickness - text_depth / 2)) + + # Text von der Karte abziehen (gravieren) + card = card.difference(text_mesh, check_volume=True) + + + + ## round corners + radius = 5 + corners = [ + (radius, radius, 0), + (card_size - radius, radius, 0), + (card_size - radius, card_size - radius, 0), + (radius, card_size - radius, 0) + ] + plate_offsets = [ + (radius / 2, radius / 2, 0), + (card_size - radius / 2, radius / 2, 0), + (card_size - radius / 2, card_size - radius / 2, 0), + (radius / 2, card_size - radius / 2, 0) + ] + for i, corner in enumerate(corners ): + plate = trimesh.creation.box(extents=(radius, radius, card_thickness)) + plate.apply_translation((plate_offsets[i][0], plate_offsets[i][1], card_thickness / 2)) + cylinder = trimesh.creation.cylinder(radius=radius, height=card_thickness, sections=64) + cylinder.apply_translation((corner[0], corner[1], corner[2] + card_thickness / 2)) + + inverse_corner = plate.difference(cylinder) + card = card.difference(inverse_corner, check_volume=True) + + + return card.export(file_type='stl') + @staticmethod def get_available_properties() -> List[str]: """ @@ -416,3 +582,4 @@ class HipsterfyPlaylist(object): """Returns the list of HipsterfyPlaylistItem objects representing the tracks in the playlist. """ return self._tracks_data + diff --git a/hipsterfy/panel_page.py b/hipsterfy/panel_page.py index c30a39e..abcd8ce 100644 --- a/hipsterfy/panel_page.py +++ b/hipsterfy/panel_page.py @@ -3,12 +3,13 @@ import base64 from io import BytesIO from hipsterfy.hipsterfy import Hipsterfy, HipsterfyPlaylist, HipsterfyPlaylistItem, CardStyle import qrcode +import traceback +import subprocess pn.extension("filedownload", "notifications", "location") - def create_panel_page(hipsterfy: Hipsterfy, playlist_uri: str=None, root_url = None) -> pn.Template: # create widgets @@ -18,6 +19,23 @@ def create_panel_page(hipsterfy: Hipsterfy, playlist_uri: str=None, root_url = N additional_items = pn.widgets.MultiChoice(name='Additional Items', options=HipsterfyPlaylistItem.get_available_properties(), value=["artists", "album", "popularity"]) card_style = pn.widgets.Select(name='Card Style', options=[style.value for style in CardStyle], value=CardStyle.FULL_ALBUM_COVER.value) cards_per_row_widget = pn.widgets.IntSlider(name="Cards per Row in print", start=1, end=8, value=4, step=1) + mode_selector = pn.widgets.RadioButtonGroup( + name="Card Generation Mode (2D for printing or 3D to produce stl files for 3D printing)", + options=["2D", "3D"], + value="2D", + button_type="primary", + button_style="outline", + sizing_mode='stretch_width' + ) + + # add a callback that hides the card style widget if 3D mode is selected + def toggle_card_style_visibility(event): + if mode_selector.value == "3D": + card_style.visible = False + else: + card_style.visible = True + + mode_selector.param.watch(toggle_card_style_visibility, 'value') pn.state.location.sync(playlist_uri, {"value": "playlist_uri"}) pn.state.location.sync(primary_item, {"value": "primary_item"}) @@ -25,6 +43,7 @@ def create_panel_page(hipsterfy: Hipsterfy, playlist_uri: str=None, root_url = N pn.state.location.sync(additional_items, {"value": "additional_items"}) pn.state.location.sync(card_style, {"value": "card_style"}) pn.state.location.sync(cards_per_row_widget, {"value": "cards_per_row"}) + pn.state.location.sync(mode_selector, {"value": "mode_selector"}) create_preview_button = pn.widgets.Button(name='Parse Playlist and Generate Cards', button_type='primary', sizing_mode='stretch_width') @@ -110,6 +129,9 @@ def create_panel_page(hipsterfy: Hipsterfy, playlist_uri: str=None, root_url = N download_back_html_button.callback = lambda: BytesIO(generate_html(cards_back, row_reverse=True)) download_back_html_button.visible = True + download_front_html_button.filename = f"hipsterfy_front_cards.html" + download_back_html_button.filename = f"hipsterfy_back_cards.html" + print_instructions.visible = True # Show print instructions after generating preview pn.state.notifications.success("Download buttons updated successfully!. Download and print them from the sidebar") @@ -121,47 +143,77 @@ def create_panel_page(hipsterfy: Hipsterfy, playlist_uri: str=None, root_url = N # clear previous cards front_cards_column.clear() back_cards_column.clear() - front_cards_column.loading = True back_cards_column.loading = True front_cards_column.append(pn.pane.HTML("

Front Cards

", sizing_mode='stretch_width')) back_cards_column.append(pn.pane.HTML("

Back Cards

", sizing_mode='stretch_width')) - # get playlist URI uri = playlist_uri.value.strip() if not uri: pn.state.notifications.error("Please enter a valid Spotify Playlist URI.") return - # create HipsterfyPlaylist instance hipsterfy_playlist = HipsterfyPlaylist(uri, hipsterfy) - new_back_cards = [] new_front_cards = [] - # generate front and back cards for each item in the playlist - for item in hipsterfy_playlist.get_tracks_data(): - front_card = item.generate_hipsterfy_front_card(include_preview=False, card_style=CardStyle(card_style.value)) - back_card = item.generate_hipsterfy_back_card( - primary_property=primary_item.value, - secondary_property=secondary_item.value, - additional_properties=additional_items.value, - card_style=CardStyle(card_style.value) - ) - new_front_cards.append(pn.pane.HTML(front_card, sizing_mode='stretch_width')) - new_back_cards.append(pn.pane.HTML(back_card, sizing_mode='stretch_width')) - - for card in new_front_cards: - front_cards_column.append(card) - for card in new_back_cards: - back_cards_column.append(card) - - pn.state.notifications.success("Preview generated successfully!") - pn.state.notifications.info("Prepare to download the cards as HTML files...") - update_download_buttons() + if mode_selector.value == "2D": + # 2D-HTML wie gehabt + for item in hipsterfy_playlist.get_tracks_data(): + front_card = item.generate_hipsterfy_front_card(include_preview=False, card_style=CardStyle(card_style.value)) + back_card = item.generate_hipsterfy_back_card( + primary_property=primary_item.value, + secondary_property=secondary_item.value, + additional_properties=additional_items.value, + card_style=CardStyle(card_style.value) + ) + new_front_cards.append(pn.pane.HTML(front_card, sizing_mode='stretch_width')) + new_back_cards.append(pn.pane.HTML(back_card, sizing_mode='stretch_width')) + for card in new_front_cards: + front_cards_column.append(card) + for card in new_back_cards: + back_cards_column.append(card) + update_download_buttons() + pn.state.notifications.success("2D preview generated successfully!") + else: + stl_buffers = [] + for idx, item in enumerate(hipsterfy_playlist.get_tracks_data()): + stl_bytes = item.generate_3d_qr_card( + primary_property=primary_item.value, + secondary_property=secondary_item.value, + additional_properties=additional_items.value, + ) + stl_filename = f"hipsterfy_card_{idx+1}.stl" + stl_buffers.append((stl_filename, stl_bytes)) + front_cards_column.append( + pn.pane.Markdown(f"**3D Card {idx+1}:** `{stl_filename}` generated.", sizing_mode='stretch_width') + ) + import zipfile + def zip_stls(): + try: + buffer = BytesIO() + with zipfile.ZipFile(buffer, "w") as zf: + for fname, stl_bytes in stl_buffers: + zf.writestr(fname, stl_bytes) + buffer.seek(0) + return buffer + except Exception as e: + print("Error creating ZIP file:", e) + traceback.print_exc() + pn.state.notifications.error(f"Error creating ZIP file: {str(e)}", duration=10000) + return None + download_front_html_button.label = "Download 3D Cards (ZIP)" + download_front_html_button.callback = zip_stls + download_front_html_button.visible = True + download_back_html_button.visible = False + download_front_html_button.filename = "hipsterfy_3d_cards.zip" + pn.state.notifications.success("3D STL files generated! Download the ZIP from the sidebar.") + print_instructions.visible = mode_selector.value == "2d" except Exception as e: - pn.state.notifications.error(f"Error generating preview: {str(e)}") + print("Error:", e) + traceback.print_exc() + pn.state.notifications.error(f"Error generating preview: {str(e)}", duration=10000) finally: front_cards_column.loading = False back_cards_column.loading = False @@ -198,7 +250,16 @@ def create_panel_page(hipsterfy: Hipsterfy, playlist_uri: str=None, root_url = N template = pn.template.FastListTemplate( title='Hipsterfy', sidebar=[ - player_qr_pane, # <-- Dynamischer QR-Code ganz oben in der Sidebar + player_qr_pane, + pn.pane.HTML("

Select card generation mode:

", sizing_mode='stretch_width'), + pn.pane.HTML( + """ +

In 2D mode, you can print the cards directly from your browser. In 3D mode, STL files will be generated for 3d printing will be generated.

+ """, + sizing_mode='stretch_width' + ), + mode_selector, + pn.layout.Divider(), playlist_instructions, playlist_uri, primary_item, secondary_item, additional_items, card_style, cards_per_row_widget, create_preview_button, download_front_html_button, download_back_html_button, print_instructions diff --git a/pyproject.toml b/pyproject.toml index 2de2155..096f0af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,19 @@ dependencies = [ "pillow", "qrcode", "panel", + "numpy", + "opencv-python", + "trimesh", + "manifold3d", + "lxml", + "svg.path", + "shapely", + "cairosvg", + "networkx", + "rtree", + "scikit-learn", ] + [tool.poetry] name = "hipsterfy" version = "0.2.3"