Surn commited on
Commit
060c90b
·
1 Parent(s): 6554386

maze update

Browse files
app.py CHANGED
@@ -37,12 +37,9 @@ import gc
37
  # Import functions from modules
38
  from utils.file_utils import (
39
  cleanup_temp_files,
40
- get_file_parts
 
41
  )
42
- from utils.storage import (
43
- generate_permalink_from_urls,
44
- upload_files_to_repo,
45
- )
46
 
47
  from utils.color_utils import (
48
  hex_to_rgb,
@@ -119,16 +116,18 @@ from diffusers import (
119
  FluxFillPipeline,
120
  DiffusionPipeline,
121
  AutoencoderTiny,
122
- AutoencoderKL
 
123
  )
124
 
125
  PIPELINE_CLASSES = {
126
  "FluxPipeline": FluxPipeline,
127
  "FluxImg2ImgPipeline": FluxImg2ImgPipeline,
128
  "FluxControlPipeline": FluxControlPipeline,
129
- "FluxFillPipeline": FluxFillPipeline
 
130
  }
131
-
132
  import utils.version_info as version_info
133
 
134
  #from utils.depth_estimation import (get_depth_map_from_state)
@@ -819,6 +818,79 @@ def replace_with_sketch_image(sketch_image, replace_current_lut_example_img: boo
819
  if replace_current_lut_example_img:
820
  current_lut_example_img = sketch
821
  return sketch
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
822
  ################################################################## DEPTH ESTIMATION ###############################################################################
823
 
824
  @spaces.GPU(progress=gr.Progress(track_tqdm=True))
@@ -1794,6 +1866,24 @@ with gr.Blocks(title=title, delete_cache=(21600,86400)) as hexaGrid:
1794
  outputs=[input_image], scroll_to_output=True
1795
  )
1796
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1797
  ##################### model #######################################
1798
  model_textbox.change(
1799
  fn=update_prompt_notes,
 
37
  # Import functions from modules
38
  from utils.file_utils import (
39
  cleanup_temp_files,
40
+ get_file_parts,
41
+ generate_permalink_from_urls
42
  )
 
 
 
 
43
 
44
  from utils.color_utils import (
45
  hex_to_rgb,
 
116
  FluxFillPipeline,
117
  DiffusionPipeline,
118
  AutoencoderTiny,
119
+ AutoencoderKL,
120
+ Flux2Pipeline, AutoModel
121
  )
122
 
123
  PIPELINE_CLASSES = {
124
  "FluxPipeline": FluxPipeline,
125
  "FluxImg2ImgPipeline": FluxImg2ImgPipeline,
126
  "FluxControlPipeline": FluxControlPipeline,
127
+ "FluxFillPipeline": FluxFillPipeline,
128
+ "Flux2Pipeline": Flux2Pipeline
129
  }
130
+ from utils.mazlib.cli import run_maze as run_maze_maker
131
  import utils.version_info as version_info
132
 
133
  #from utils.depth_estimation import (get_depth_map_from_state)
 
818
  if replace_current_lut_example_img:
819
  current_lut_example_img = sketch
820
  return sketch
821
+
822
+ def update_maze_maker_shape_visibility(shape):
823
+ shape_key = (shape or "rect").lower()
824
+ is_hex = shape_key in ("hex", "hexagon", "hexagonal")
825
+ # show width/height for non-hex, show radius for hex
826
+ return (
827
+ gr.update(visible=not is_hex),
828
+ gr.update(visible=not is_hex),
829
+ gr.update(visible=is_hex),
830
+ )
831
+
832
+
833
+ def generate_and_apply_maze_to_input(shape, width, height, radius, cell_size, use_iterative, show_solution, show_doors, input_image, opacity_percent):
834
+ shape_key = (shape or "rect").lower()
835
+
836
+ if shape_key in ("hex", "hexagon", "hexagonal"):
837
+ normalized_size = max(1, int(radius))
838
+ else:
839
+ w = max(5, int(width))
840
+ h = max(5, int(height))
841
+ if w % 2 == 0:
842
+ w += 1
843
+ if h % 2 == 0:
844
+ h += 1
845
+ normalized_size = (w, h)
846
+
847
+ with NamedTemporaryFile(delete=False, suffix=".png", prefix="maze_maker_") as tmp_maze:
848
+ maze_output_path = tmp_maze.name
849
+
850
+ try:
851
+ _, solution_path = run_maze_maker(
852
+ shape=shape_key,
853
+ size=normalized_size,
854
+ cell=max(4, int(cell_size)),
855
+ use_iterative=bool(use_iterative),
856
+ draw=True,
857
+ output=maze_output_path,
858
+ show_solution=bool(show_solution),
859
+ show_doors=bool(show_doors),
860
+ )
861
+ constants.temp_files.append(maze_output_path)
862
+ except Exception as ex:
863
+ raise gr.Error(f"Failed to generate maze: {ex}")
864
+
865
+ path_info = f"path nodes: {len(solution_path)}" if solution_path else "no solution path found"
866
+ status = f"Maze created ({shape_key}, size={normalized_size}, {path_info}) and applied to Input Image."
867
+
868
+ # If there's no input image provided, return the generated maze image path
869
+ # (returning a file path avoids Gradio creating temporary PIL files which
870
+ # previously led to intermittent FileNotFoundError on chained calls).
871
+ if not input_image:
872
+ return maze_output_path, status
873
+
874
+ try:
875
+ base_img = open_image(input_image) if isinstance(input_image, str) else input_image
876
+ overlay = open_image(maze_output_path)
877
+ # ensure RGBA and resize overlay to match base image
878
+ if overlay.mode != 'RGBA':
879
+ overlay = overlay.convert('RGBA')
880
+ if overlay.size != base_img.size:
881
+ overlay = overlay.resize(base_img.size, Image.LANCZOS)
882
+
883
+ result = alpha_composite_with_control(base_img, overlay, opacity_percent)
884
+
885
+ # save to temp file and return path
886
+ with NamedTemporaryFile(delete=False, suffix=".png", prefix="maze_composite_") as outf:
887
+ result.convert('RGBA').save(outf.name, format='PNG')
888
+ constants.temp_files.append(outf.name)
889
+ return outf.name, status
890
+ except Exception as ex:
891
+ # fallback: return generated maze image if compositing fails
892
+ print(f"Maze compositing failed: {ex}")
893
+ return maze_output_path, status
894
  ################################################################## DEPTH ESTIMATION ###############################################################################
895
 
896
  @spaces.GPU(progress=gr.Progress(track_tqdm=True))
 
1866
  outputs=[input_image], scroll_to_output=True
1867
  )
1868
 
1869
+ # Maze Maker handlers
1870
+ maze_shape.change(
1871
+ fn=update_maze_maker_shape_visibility,
1872
+ inputs=[maze_shape],
1873
+ outputs=[maze_width, maze_height, maze_radius]
1874
+ )
1875
+
1876
+ maze_apply_button.click(
1877
+ fn=generate_and_apply_maze_to_input,
1878
+ inputs=[maze_shape, maze_width, maze_height, maze_radius, maze_cell_size, maze_use_iterative, maze_show_solution, maze_show_doors, input_image, maze_opacity],
1879
+ outputs=[input_image, maze_status],
1880
+ scroll_to_output=True
1881
+ ).then(
1882
+ fn=update_sketch_dimensions,
1883
+ inputs=[input_image, sketch_image],
1884
+ outputs=[sketch_image, sketch_image]
1885
+ )
1886
+
1887
  ##################### model #######################################
1888
  model_textbox.change(
1889
  fn=update_prompt_notes,
requirements.txt CHANGED
@@ -106,3 +106,10 @@ https://huggingface.co/spaces/Surn/HexaGrid/resolve/main/wheels/diff_gaussian_ra
106
  #https://github.com/PozzettiAndrea/cuda-wheels/releases/download/nvdiffrast-latest/nvdiffrast-0.4.0%2Bcu128torch2.9-cp312-cp312-win_amd64.whl
107
 
108
  #https://github.com/PozzettiAndrea/cuda-wheels/releases/download/nvdiffrast-latest/nvdiffrast-0.4.0%2Bcu128torch2.10-cp312-cp312-win_amd64.whl
 
 
 
 
 
 
 
 
106
  #https://github.com/PozzettiAndrea/cuda-wheels/releases/download/nvdiffrast-latest/nvdiffrast-0.4.0%2Bcu128torch2.9-cp312-cp312-win_amd64.whl
107
 
108
  #https://github.com/PozzettiAndrea/cuda-wheels/releases/download/nvdiffrast-latest/nvdiffrast-0.4.0%2Bcu128torch2.10-cp312-cp312-win_amd64.whl
109
+
110
+ # kaolin update
111
+
112
+ tornado
113
+ flask
114
+ kaolin @ https://huggingface.co/spaces/Surn/HexaGrid/resolve/main/wheels/kaolin-0.18.0-cp312-cp312-linux_x86_64.whl ; sys_platform == "linux"
115
+ kaolin @ https://huggingface.co/spaces/Surn/HexaGrid/resolve/main/wheels/kaolin-0.18.0-cp312-cp312-win_amd64.whl ; sys_platform == "win32"
style_20250314.css CHANGED
@@ -21,7 +21,7 @@
21
  /*background-color: rgba(242, 218, 163, 0.62);*/
22
  }
23
 
24
- .dark .gradio-container.gradio-container-5-29-1 .contain .intro .prose {
25
  /*background-color: rgba(41, 18, 5, 0.38) !important;*/
26
  }
27
  .toast-body.info {
 
21
  /*background-color: rgba(242, 218, 163, 0.62);*/
22
  }
23
 
24
+ .dark .gradio-container.gradio-container-6-10-0 .contain .intro .prose {
25
  /*background-color: rgba(41, 18, 5, 0.38) !important;*/
26
  }
27
  .toast-body.info {
utils/constants.py CHANGED
@@ -68,7 +68,6 @@ TMPDIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'tmp')
68
  os.makedirs(TMPDIR, exist_ok=True)
69
 
70
 
71
-
72
  PROMPTS = {
73
  "Mecha Wasteland Arena": "Regional overhead view, directly from above, centered on the map, orthographic Mecha battlefield map. post-industrial wasteland with crumbling structures, volcanic ridges, scrapyards, and ash plains. Features elevated overwatch positions for long-range combat and tight brawling areas for close-quarters engagements. Partial edge hexes are black. Colors: red, gray, muted orange, ash white, dark brown.",
74
  "BorderBlack": "Planetary overhead view, directly from above, centered on the planet’s surface, orthographic hexagon-based alien world map with black borders. Features rivers, mountains, volcanoes, and polar snow regions. Colors: light blue, green, tan, brown. No reflections or shadows. Partial edge hexes are black.",
@@ -209,7 +208,8 @@ MODELS = [
209
  "black-forest-labs/FLUX.1-schnell",
210
  "black-forest-labs/FLUX.1-dev",
211
  "ostris/Flex.1-alpha",
212
- "black-forest-labs/FLUX.1-Fill-dev"
 
213
  ]
214
  # Available LoRA weights
215
  LORA_WEIGHTS = [
@@ -227,6 +227,8 @@ LORA_WEIGHTS = [
227
  "gokaygokay/Flux-Seamless-Texture-LoRA",
228
  "gokaygokay/Flux-Game-Assets-LoRA-v2",
229
  "lrzjason/ObjectRemovalFluxFill",
 
 
230
  ]
231
 
232
  # Map each LoRA weight to its corresponding model
@@ -246,6 +248,8 @@ LORA_TO_MODEL = {
246
  "gokaygokay/Flux-Seamless-Texture-LoRA":"black-forest-labs/FLUX.1-dev",
247
  "gokaygokay/Flux-Game-Assets-LoRA-v2":"black-forest-labs/FLUX.1-dev",
248
  "lrzjason/ObjectRemovalFluxFill":"black-forest-labs/FLUX.1-Fill-dev",
 
 
249
  }
250
  condition_type = ["depth", "canny", "subject", "coloring", "deblurring", "fill", "redux"]
251
 
@@ -489,7 +493,7 @@ LORA_DETAILS = {
489
  },
490
  {
491
  "thumbnail": "https://huggingface.co/Borcherding/FLUX.1-dev-LoRA-FractalLand-v0.1/resolve/main/images/example_e2zoqwftv.png",
492
- "show_in_gallery": True
493
  }
494
  ],
495
  "strangerzonehf/Flux-Cute-3D-Kawaii-LoRA": [
@@ -585,6 +589,22 @@ LORA_DETAILS = {
585
  "show_in_gallery": False
586
  }
587
  ],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
588
  }
589
 
590
  def sort_loras(sortby):
@@ -686,4 +706,9 @@ audio_extensions = {".mp3", ".wav", ".ogg", ".flac", ".aac"}
686
  audio_extensions_list = list(audio_extensions)
687
  video_extensions = {".mp4"}
688
  video_extensions_list = list(video_extensions)
689
- upload_file_types = model_extensions_list + image_extensions_list + audio_extensions_list + video_extensions_list
 
 
 
 
 
 
68
  os.makedirs(TMPDIR, exist_ok=True)
69
 
70
 
 
71
  PROMPTS = {
72
  "Mecha Wasteland Arena": "Regional overhead view, directly from above, centered on the map, orthographic Mecha battlefield map. post-industrial wasteland with crumbling structures, volcanic ridges, scrapyards, and ash plains. Features elevated overwatch positions for long-range combat and tight brawling areas for close-quarters engagements. Partial edge hexes are black. Colors: red, gray, muted orange, ash white, dark brown.",
73
  "BorderBlack": "Planetary overhead view, directly from above, centered on the planet’s surface, orthographic hexagon-based alien world map with black borders. Features rivers, mountains, volcanoes, and polar snow regions. Colors: light blue, green, tan, brown. No reflections or shadows. Partial edge hexes are black.",
 
208
  "black-forest-labs/FLUX.1-schnell",
209
  "black-forest-labs/FLUX.1-dev",
210
  "ostris/Flex.1-alpha",
211
+ "black-forest-labs/FLUX.1-Fill-dev",
212
+ "diffusers/FLUX.2-dev-bnb-4bit",
213
  ]
214
  # Available LoRA weights
215
  LORA_WEIGHTS = [
 
227
  "gokaygokay/Flux-Seamless-Texture-LoRA",
228
  "gokaygokay/Flux-Game-Assets-LoRA-v2",
229
  "lrzjason/ObjectRemovalFluxFill",
230
+ "strangerzonehf/Flux-Super-Realism-LoRA",
231
+ "fal/FLUX.2-dev-Turbo",
232
  ]
233
 
234
  # Map each LoRA weight to its corresponding model
 
248
  "gokaygokay/Flux-Seamless-Texture-LoRA":"black-forest-labs/FLUX.1-dev",
249
  "gokaygokay/Flux-Game-Assets-LoRA-v2":"black-forest-labs/FLUX.1-dev",
250
  "lrzjason/ObjectRemovalFluxFill":"black-forest-labs/FLUX.1-Fill-dev",
251
+ "fal/FLUX.2-dev-Turbo": "black-forest-labs/FLUX.2-dev-Turbo",
252
+ "strangerzonehf/Flux-Super-Realism-LoRA": "black-forest-labs/FLUX.2-dev-Turbo"
253
  }
254
  condition_type = ["depth", "canny", "subject", "coloring", "deblurring", "fill", "redux"]
255
 
 
493
  },
494
  {
495
  "thumbnail": "https://huggingface.co/Borcherding/FLUX.1-dev-LoRA-FractalLand-v0.1/resolve/main/images/example_e2zoqwftv.png",
496
+ "show_in_gallery": False
497
  }
498
  ],
499
  "strangerzonehf/Flux-Cute-3D-Kawaii-LoRA": [
 
589
  "show_in_gallery": False
590
  }
591
  ],
592
+ "fal/FLUX.2-dev-Turbo": [
593
+ {"weight_name": "flux.2-turbo-lora.safetensors"},
594
+ {"title": "FLUX.2 Turbo"},
595
+ {"trigger_words": "https://huggingface.co/spaces/Surn/HexaGrid/resolve/main/images/prerendered/flux2-turbo-lora.png"},
596
+ {"notes": "Turbo LoRA for 8-step fast generation."},
597
+ {"parameters": {"num_inference_steps": "8", "guidance_scale": "2.5"}},
598
+ {"thumbnail": "images/prerendered/flux2-turbo-lora.png", "show_in_gallery": True}
599
+ ],
600
+ "strangerzonehf/Flux-Super-Realism-LoRA": [
601
+ {"trigger_words": "Super Realism, "},
602
+ {"weight_name": "super-realism.safetensors"},
603
+ {"title": "Super Realism"},
604
+ {"notes": "Excellent photorealism. Use 'realistic, photoreal' keywords too."},
605
+ {"parameters": {"num_inference_steps": "28", "guidance_scale": "3.5"}},
606
+ {"thumbnail": "https://huggingface.co/strangerzonehf/Flux-Super-Realism-LoRA/resolve/main/images/sz.png", "show_in_gallery": True}
607
+ ]
608
  }
609
 
610
  def sort_loras(sortby):
 
706
  audio_extensions_list = list(audio_extensions)
707
  video_extensions = {".mp4"}
708
  video_extensions_list = list(video_extensions)
709
+ # BattleWords storage module expects document types to be part of uploadable types.
710
+ # Keep this minimal for HexaGrid: JSON is used by the URL shortener registry.
711
+ doc_extensions = {".json"}
712
+ doc_extensions_list = list(doc_extensions)
713
+
714
+ upload_file_types = model_extensions_list + image_extensions_list + audio_extensions_list + video_extensions_list + doc_extensions_list
utils/file_utils.py CHANGED
@@ -103,4 +103,23 @@ def get_unique_file_path(directory, filename, file_ext, counter=0):
103
  return get_unique_file_path(directory, filename, file_ext, counter + 1)
104
 
105
  # Example usage:
106
- # new_file_path = get_unique_file_path(video_dir, title_file_name, video_new_ext)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  return get_unique_file_path(directory, filename, file_ext, counter + 1)
104
 
105
  # Example usage:
106
+ # new_file_path = get_unique_file_path(video_dir, title_file_name, video_new_ext)
107
+
108
+ def generate_permalink_from_urls(model_url, hm_url, img_url, permalink_viewer_url="surn-3d-viewer.hf.space"):
109
+ """
110
+ Constructs and returns a permalink URL with query string parameters for the viewer.
111
+ Each parameter is passed separately so that the image positions remain consistent.
112
+
113
+ Parameters:
114
+ model_url (str): Processed URL for the 3D model.
115
+ hm_url (str): Processed URL for the height map image.
116
+ img_url (str): Processed URL for the main image.
117
+ permalink_viewer_url (str): The base viewer URL.
118
+
119
+ Returns:
120
+ str: The generated permalink URL.
121
+ """
122
+ import urllib.parse
123
+ params = {"3d": model_url, "hm": hm_url, "image": img_url}
124
+ query_str = urllib.parse.urlencode(params)
125
+ return f"https://{permalink_viewer_url}/?{query_str}"
utils/image_utils.py CHANGED
@@ -6,7 +6,7 @@ import base64
6
  import numpy as np
7
  import rembg
8
  #from decimal import ROUND_CEILING
9
- from PIL import Image, ImageChops, ImageDraw, ImageEnhance, ImageFilter, ImageDraw, ImageOps, ImageMath
10
  from typing import List, Union, is_typeddict
11
  #import numpy as np
12
  #import math
@@ -1444,4 +1444,98 @@ def combine_depth_map_with_Image(image:Image, depth_img:Image, width:int, height
1444
  resized_depth_image = resize_image_with_aspect_ratio(depth_img, width, height)
1445
  depth_no_background = rembg.remove(resized_depth_image, session = rembg.new_session(rembg_session_name))
1446
  combined_depth_img = multiply_and_blend_images(image, depth_no_background, (alpha / 100))
1447
- return combined_depth_img
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  import numpy as np
7
  import rembg
8
  #from decimal import ROUND_CEILING
9
+ from PIL import Image, ImageChops, ImageDraw, ImageEnhance, ImageFilter, ImageDraw, ImageOps, ImageMath, ImageFont
10
  from typing import List, Union, is_typeddict
11
  #import numpy as np
12
  #import math
 
1444
  resized_depth_image = resize_image_with_aspect_ratio(depth_img, width, height)
1445
  depth_no_background = rembg.remove(resized_depth_image, session = rembg.new_session(rembg_session_name))
1446
  combined_depth_img = multiply_and_blend_images(image, depth_no_background, (alpha / 100))
1447
+ return combined_depth_img
1448
+
1449
+ def add_watermark(
1450
+ original_img: "Image.Image",
1451
+ watermark_img: "Image.Image",
1452
+ overlay_text: str = "",
1453
+ watermark_size: tuple[int, int] = (100, 100),
1454
+ watermark_position: tuple[int, int] = None,
1455
+ text_position: tuple[str, str] = ("bottom", "right"),
1456
+ text_color: tuple[int, int, int, int] = (107, 78, 156, 255),
1457
+ text_font_path: str = "fonts/Roboto-Regular.ttf",
1458
+ text_font_size: int = 20,
1459
+ blend_alpha: float = 0.4,
1460
+ dpi: tuple[int, int] = None
1461
+ ) -> "Image.Image":
1462
+ """
1463
+ Adds a watermark overlay onto the original image by pasting the watermark image
1464
+ (resized to watermark_size) and overlaying the provided text.
1465
+
1466
+ Parameters:
1467
+ original_img (Image.Image): The input image to watermark. Expected in RGBA mode.
1468
+ watermark_img (Image.Image): The watermark image to overlay. Expected in RGBA mode.
1469
+ overlay_text (str): Optional text to overlay on the image.
1470
+ watermark_size (tuple[int, int]): The desired size for the watermark image.
1471
+ watermark_position (tuple[int, int]): The position (x, y) where the watermark image will be placed.
1472
+ text_position (tuple[str, str]): (vertical, horizontal) position: ("top"|"middle"|"center"|"bottom", "left"|"center"|"middle"|"right")
1473
+ text_color (tuple[int, int, int, int]): The RGBA color for the overlay text.
1474
+ text_font_path (str): Path to the TTF font file used for the overlay text.
1475
+ text_font_size (int): Font size for the overlay text.
1476
+ blend_alpha (float): The alpha value (0.0 to 1.0) for blending the watermark layer with the original image.
1477
+ dpi (tuple[int, int]): DPI to use for output. If None, uses original image DPI if available.
1478
+
1479
+ Returns:
1480
+ Image.Image: The watermarked image.
1481
+ """
1482
+ # Ensure original is RGBA
1483
+ original = original_img.convert("RGBA")
1484
+ width, height = original.size
1485
+
1486
+ # Set default positions if not provided
1487
+ margin = 10
1488
+ if watermark_position is None:
1489
+ watermark_position = (width - watermark_size[0] - margin, height - watermark_size[1] - margin)
1490
+
1491
+ # Resize watermark image
1492
+ watermark_resized = watermark_img.resize(watermark_size, Image.Resampling.LANCZOS)
1493
+
1494
+ # Create an empty transparent layer for watermark
1495
+ watermark_layer = Image.new("RGBA", original.size, (0, 0, 0, 0))
1496
+ # Paste the watermark image onto the watermark layer at the specified position
1497
+ watermark_layer.paste(watermark_resized, watermark_position, watermark_resized)
1498
+
1499
+ # Draw overlay text if provided
1500
+ if overlay_text:
1501
+ draw = ImageDraw.Draw(watermark_layer)
1502
+ try:
1503
+ font = ImageFont.truetype(text_font_path, text_font_size)
1504
+ except Exception as e:
1505
+ print(f"Failed to load font {text_font_path}: {e}")
1506
+ font = ImageFont.load_default()
1507
+ # Calculate text size
1508
+ text_bbox = draw.textbbox((0, 0), overlay_text, font=font)
1509
+ text_width = text_bbox[2] - text_bbox[0]
1510
+ text_height = text_bbox[3] - text_bbox[1]
1511
+ # Determine text position based on (vertical, horizontal) keywords
1512
+ vertical, horizontal = text_position
1513
+ # Place text over the watermark image
1514
+ wm_x, wm_y = watermark_position
1515
+ wm_w, wm_h = watermark_size
1516
+ # Default to center of watermark
1517
+ x = wm_x + (wm_w - text_width) // 2
1518
+ y = wm_y + (wm_h - text_height) // 2
1519
+ if vertical == "top":
1520
+ y = wm_y + margin
1521
+ elif vertical in ("middle", "center"):
1522
+ y = wm_y + (wm_h - text_height) // 2
1523
+ elif vertical == "bottom":
1524
+ y = wm_y + wm_h - text_height - margin
1525
+ if horizontal == "left":
1526
+ x = wm_x + margin
1527
+ elif horizontal in ("middle", "center"):
1528
+ x = wm_x + (wm_w - text_width) // 2
1529
+ elif horizontal == "right":
1530
+ x = wm_x + wm_w - text_width - margin
1531
+ draw.text((x, y), overlay_text, fill=text_color, font=font)
1532
+
1533
+ # Blend the watermark layer over the original image using the specified alpha
1534
+ watermarked = Image.blend(original, watermark_layer, alpha=blend_alpha)
1535
+ # Preserve DPI if possible
1536
+ if dpi is None:
1537
+ dpi = original.info.get("dpi", (72, 72))
1538
+ # Ensure output image has original size
1539
+ watermarked = watermarked.resize((width, height), Image.Resampling.LANCZOS)
1540
+ watermarked.info["dpi"] = dpi
1541
+ return watermarked
utils/mazlib/README.md ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ # Maze Maker
3
+
4
+ Generate, solve, and render mazes in Python.
5
+
6
+ ## Features
7
+
8
+ - Rectangular mazes (recursive or iterative DFS)
9
+ - Triangular mazes
10
+ - Hexagonal mazes (axial coordinates)
11
+ - Shortest-path solving with BFS
12
+ - Image rendering with Pillow
13
+ - CLI and Python API support
14
+ - Deterministic generation via --seed / seed parameter (non‑zero)
15
+
16
+ ## Installation
17
+
18
+ Requirements:
19
+
20
+ - Python 3.8+
21
+ - Pillow>=9.0
22
+
23
+ Install from source:
24
+
25
+ ```sh
26
+ git clone https://github.com/Oncorporation/maze_maker.git
27
+ cd maze_maker
28
+ python -m pip install --upgrade pip
29
+ pip install -r requirements.txt
30
+ ```
31
+
32
+ Optional editable install:
33
+
34
+ ```sh
35
+ pip install -e .
36
+ ```
37
+
38
+ ## Usage
39
+
40
+ CLI:
41
+
42
+ ```sh
43
+ python -m mazlib.cli --help
44
+ python -m mazlib.cli -s rect -z 31x21 -c 20 -O out --show-solution
45
+ ```
46
+
47
+ Python API:
48
+
49
+ ```python
50
+ from mazlib.cli import run_maze
51
+
52
+ # non-deterministic (seed=0 uses system randomness)
53
+ maze, path = run_maze(shape="rect", size=(31, 21), cell=20, draw=True)
54
+
55
+ # deterministic: pass a non-zero seed
56
+ maze, path = run_maze(shape="rect", size=(31, 21), cell=20, draw=True, seed=42)
57
+ ```
58
+
59
+ ### `run_maze` parameters
60
+
61
+ `run_maze(shape='rect', size=(30,20), cell=20, use_iterative=False, draw=True, output=None, show_solution=True, show_doors=False, seed=0)`
62
+
63
+ - `shape` (`str`): maze shape.
64
+ - Canonical values: `rect`, `tri`, `hex`
65
+ - Also accepted by `run_maze`: `rectangle`, `grid`, `triangle`, `triangular`, `hexagon`, `hexagonal`
66
+ - `size` (`tuple|int`):
67
+ - Rect/Tri: `(width, height)` or single `int` (square)
68
+ - Hex: `int` radius
69
+ - `cell` (`int`): cell size in pixels (`cell_size` for hex drawing internally).
70
+ - `use_iterative` (`bool`): use iterative generator variant.
71
+ - `draw` (`bool`): when `True`, render maze image.
72
+ - `output` (`str|None`): output image path. If `None`, no file is saved and the image is shown (when drawing).
73
+ - `show_solution` (`bool`): when `True`, include the solved path overlay in rendering.
74
+ - `show_doors` (`bool`): draw light outline guides/cell boundaries.
75
+ - `seed` (`int`): optional random seed. Non-zero seeds produce deterministic mazes; `0` (default) uses system randomness.
76
+
77
+ Return value:
78
+
79
+ - `(maze, path)`
80
+ - `maze`: list of lists (rect) or dict (tri/hex)
81
+ - `path`: list of coordinates for the solution path
82
+
83
+ Notes on determinism and seeding
84
+
85
+ - Passing a non-zero `seed` to `run_maze` or the individual generators (`generate_maze`,
86
+ `generate_maze_iterative`, `generate_hex_maze_iterative`, `generate_tri_maze`, etc.)
87
+ uses a private Random instance seeded with that value. That makes generation deterministic
88
+ and repeatable across runs when the same seed is used.
89
+
90
+ ### Valid shapes in CLI vs API
91
+
92
+ - CLI `--shape` accepted values: `rect`, `tri`, `hex`
93
+ - Python `run_maze(shape=...)` accepts the aliases listed above
94
+
95
+ ### Image file naming with `mazlib.cli`
96
+
97
+ `run_maze` supports two output modes from the CLI:
98
+
99
+ - `-o/--output`: use an exact filename you provide.
100
+ - `-O/--out-dir`: auto-generate a filename in the given directory.
101
+
102
+ Auto-generated filename patterns (when using `-O`):
103
+
104
+ - Rectangular: `maze_rect_<width>x<height>_<YYYYMMDD_HHMMSS>.png`
105
+ - Triangular: `maze_tri_<width>x<height>_<YYYYMMDD_HHMMSS>.png`
106
+ - Hexagonal: `maze_hex_r<radius>_<YYYYMMDD_HHMMSS>.png`
107
+
108
+ When a non-zero `--seed` is provided to the CLI, the seed is appended to the
109
+ auto-generated filename as `_s<seed>` (for example `maze_rect_31x21_20230101_123000_s42.png`) so
110
+ deterministic outputs are easy to correlate with their seed.
111
+
112
+ Examples:
113
+
114
+ ```sh
115
+ # Explicit filename
116
+ python -m mazlib.cli -s rect -z 31x21 -o my_custom_maze.png
117
+
118
+ # Auto-generated filename in ./out with deterministic seed appended
119
+ python -m mazlib.cli -s rect -z 31x21 -O out --seed 42
120
+ ```
121
+
122
+ ### Drawing customization and colors
123
+
124
+ The CLI exposes options to control drawing appearance and colors. Use `--show-doors` to draw thin outlines for cell/hex/triangle doors. You can override individual colors with these flags:
125
+
126
+ - `--color-wall` (default: `black`)
127
+ - `--color-floor` (default: `white`)
128
+ - `--color-start` (default: `green`)
129
+ - `--color-door` (default: `#B0B0B0`)
130
+ - `--color-path` (default: `red`)
131
+
132
+ Example (custom colors):
133
+
134
+ ```sh
135
+ python -m mazlib.cli -s rect -z 31x21 -O out --show-doors --color-wall navy --color-start lime --color-path orange
136
+ ```
137
+
138
+ Programmatic API users can pass a color_dict to `run_maze` or to the draw functions:
139
+
140
+ ```python
141
+ colors = {'wall':'navy', 'floor':'#f8f8f8', 'start':'lime', 'door':'#cccccc', 'path':'orange'}
142
+ maze, path = run_maze(shape='rect', size=(31,21), draw=True, seed=42, color_dict=colors)
143
+ ```
144
+
145
+ ### Drawing notes
146
+
147
+ - The triangular maze drawing now reliably marks the starting triangle in green
148
+ (same visual cue used by rectangular and hex drawing functions).
149
+
150
+ ## Git Submodule (quick reference)
151
+
152
+ Add:
153
+
154
+ ```sh
155
+ git submodule add https://github.com/Oncorporation/maze_maker.git submodules/maze_maker
156
+ ```
157
+
158
+ Clone with submodules:
159
+
160
+ ```sh
161
+ git clone --recurse-submodules <parent-repo-url>
162
+ ```
163
+
164
+ Update after clone:
165
+
166
+ ```sh
167
+ git submodule update --init --recursive
168
+ ```
169
+
utils/mazlib/__init__.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Public API for mazlib package.
2
+
3
+ Re-exports generators, solvers and drawing helpers for rectangular and hex
4
+ mazes. This package is a lightweight refactor of the original top-level
5
+ maz.py to make the code easier to test and reuse.
6
+ """
7
+ from .grid import (
8
+ generate_maze,
9
+ generate_maze_iterative,
10
+ solve_maze,
11
+ )
12
+ from .hex import (
13
+ generate_hex_maze,
14
+ generate_hex_maze_iterative,
15
+ solve_hex_maze,
16
+ )
17
+ from .draw import (
18
+ draw_maze,
19
+ draw_hex_maze,
20
+ draw_tri_maze,
21
+ )
22
+ from .tri import (
23
+ generate_tri_maze,
24
+ generate_tri_maze_iterative,
25
+ solve_tri_maze,
26
+ )
27
+ from .cli import run_maze
28
+
29
+ __all__ = [
30
+ 'generate_maze', 'generate_maze_iterative', 'solve_maze',
31
+ 'generate_hex_maze', 'generate_hex_maze_iterative', 'solve_hex_maze',
32
+ 'draw_maze', 'draw_hex_maze', 'run_maze',
33
+ 'generate_tri_maze', 'generate_tri_maze_iterative', 'solve_tri_maze', 'draw_tri_maze',
34
+ ]
utils/mazlib/cli.py ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .grid import generate_maze, generate_maze_iterative, solve_maze
2
+ from .hex import generate_hex_maze, generate_hex_maze_iterative, solve_hex_maze
3
+ from .draw import draw_maze, draw_hex_maze, draw_tri_maze
4
+ from .tri import generate_tri_maze, generate_tri_maze_iterative, solve_tri_maze
5
+ import argparse
6
+ import sys
7
+ import logging
8
+ import os
9
+ import time
10
+ from pathlib import Path
11
+
12
+
13
+ def run_maze(shape='rect', size=(30,20), cell=20, use_iterative=False, draw=True, output=None, show_solution=True, show_doors=False, seed=0, color_dict={'wall':'black', 'floor':'white', 'start':'green', 'door':'#B0B0B0', 'path':'red'}):
14
+ """
15
+ Control function to generate, solve, and optionally draw a maze.
16
+
17
+ Args:
18
+ shape (str): 'rect' for rectangular grid mazes or 'hex' for hexagonal mazes.
19
+ size (tuple|int): For 'rect', a (width, height) tuple. For 'hex', an integer radius.
20
+ cell (int): Cell size in pixels for drawing (rect) or approximate hex size (hex).
21
+ use_iterative (bool): If True and shape is 'rect', use the iterative DFS generator.
22
+ draw (bool): If True, render the maze and path with PIL; otherwise only return data.
23
+ output (str|None): If provided, save the rendered image to this path; if None, show the image.
24
+ show_solution (bool): If True, overlay the solution path on the drawn maze.
25
+ show_doors (bool): If True, draw thin grey outlines around each cell to show doors.
26
+ seed (int): Random seed for maze generation (0 for random).
27
+ color_dict (dict): Optional dict to specify colors for 'wall', 'floor', 'start', 'door', and 'path' in drawing.
28
+
29
+ Returns:
30
+ tuple: (maze, path) where maze is either a 2D list (rect) or dict (hex), and
31
+ path is a list of coordinates in the corresponding coordinate system.
32
+ """
33
+ logging.debug('run_maze called: shape=%s size=%s cell=%s iterative=%s draw=%s output=%s show_solution=%s',
34
+ shape, size, cell, use_iterative, draw, output, show_solution)
35
+ shape_key = (shape or 'rect').lower()
36
+ if shape_key in ('rect', 'rectangle', 'grid'):
37
+ # Normalize size into (w, h)
38
+ if isinstance(size, int):
39
+ w, h = size, size
40
+ else:
41
+ try:
42
+ w, h = int(size[0]), int(size[1])
43
+ except Exception:
44
+ raise ValueError("For rect shape, size must be an int or a (w,h) tuple")
45
+ if use_iterative:
46
+ maze = generate_maze_iterative(w, h, seed=seed)
47
+ else:
48
+ maze = generate_maze(w, h, seed=seed)
49
+ path = solve_maze(maze)
50
+ if draw:
51
+ draw_maze(maze, path if show_solution else [], cell=cell, save_path=output, show_doors=show_doors, color_dict=color_dict)
52
+ return maze, path
53
+
54
+ if shape_key in ('tri', 'triangle', 'triangular'):
55
+ # triangular grids are currently implemented as rectangular compatibility wrappers
56
+ if isinstance(size, int):
57
+ w, h = size, size
58
+ else:
59
+ try:
60
+ w, h = int(size[0]), int(size[1])
61
+ except Exception:
62
+ raise ValueError("For tri shape, size must be an int or a (w,h) tuple")
63
+ if use_iterative:
64
+ maze = generate_tri_maze_iterative(w, h, seed=seed)
65
+ else:
66
+ maze = generate_tri_maze(w, h, seed=seed)
67
+ path = solve_tri_maze(maze)
68
+ if draw:
69
+ draw_tri_maze(maze, path if show_solution else [], cell=cell, save_path=output, show_doors=show_doors, color_dict=color_dict)
70
+ return maze, path
71
+
72
+ if shape_key in ('hex', 'hexagon', 'hexagonal'):
73
+ try:
74
+ radius = int(size)
75
+ except Exception:
76
+ raise ValueError("For hex shape, size must be an integer radius")
77
+ # allow choosing iterative implementation explicitly
78
+ if use_iterative:
79
+ maze = generate_hex_maze_iterative(radius, seed=seed)
80
+ else:
81
+ maze = generate_hex_maze(radius, seed=seed)
82
+ path = solve_hex_maze(maze)
83
+ if draw:
84
+ draw_hex_maze(maze, path if show_solution else [], cell_size=cell, save_path=output, show_doors=show_doors, color_dict=color_dict)
85
+ return maze, path
86
+
87
+ raise ValueError(f"Unknown shape: {shape}")
88
+
89
+
90
+ def _parse_size_arg(shape, size_str):
91
+ """Parse the --size CLI argument into the appropriate value for run_maze.
92
+
93
+ For rectangular mazes accept either an integer (square) or WIDTHxHEIGHT.
94
+ For hex mazes accept a single integer radius.
95
+ """
96
+ if shape in ('rect', 'rectangle', 'grid', 'tri', 'triangle', 'triangular'):
97
+ if isinstance(size_str, (list, tuple)):
98
+ return int(size_str[0]), int(size_str[1])
99
+ s = str(size_str)
100
+ if 'x' in s.lower():
101
+ parts = s.lower().split('x')
102
+ if len(parts) != 2:
103
+ raise ValueError("Invalid rect size format, expected WIDTHxHEIGHT")
104
+ return int(parts[0]), int(parts[1])
105
+ return int(s)
106
+ # hex
107
+ return int(size_str)
108
+
109
+
110
+ def main(argv=None):
111
+ parser = argparse.ArgumentParser(prog='run_maze', description='Generate/solve/draw mazes')
112
+ parser.add_argument('-s', '--shape', choices=['rect', 'hex', 'tri'], default='rect', help='Maze shape')
113
+ parser.add_argument('-z', '--size', default='30x20', help='Size: for rect WIDTHxHEIGHT or N; for hex radius')
114
+ parser.add_argument('-c', '--cell', type=int, default=20, help='Cell size in pixels')
115
+ parser.add_argument('-i', '--iterative', action='store_true', help='Use iterative generator')
116
+ parser.add_argument('--no-draw', dest='draw', action='store_false', help="Don't render the maze")
117
+ group = parser.add_mutually_exclusive_group()
118
+ group.add_argument('-o', '--output', default=None, help='Path to save rendered image (if omitted, image will be shown)')
119
+ group.add_argument('-O', '--out-dir', default=None, help='Directory to save rendered image(s); filename auto-generated')
120
+ parser.add_argument('-v', '--verbose', action='count', default=0, help='Increase verbosity (use -v or -vv)')
121
+ parser.add_argument('--show-solution', action='store_true', default=False,
122
+ help='Print path length and endpoints after solving')
123
+ parser.add_argument('--seed', type=int, default=0, help='Random seed (0 for random)')
124
+ parser.add_argument('--show-doors', action='store_true', default=False, help='Draw thin grey outlines for doors')
125
+ parser.add_argument('--color-wall', default='black', help='Color for walls in drawing')
126
+ parser.add_argument('--color-floor', default='white', help='Color for floors in drawing')
127
+ parser.add_argument('--color-start', default='green', help='Color for start cell in drawing')
128
+ parser.add_argument('--color-door', default='#B0B0B0', help='Color for doors in drawing')
129
+ parser.add_argument('--color-path', default='red', help='Color for solution path in drawing')
130
+ args = parser.parse_args(argv)
131
+
132
+ # configure logging according to verbosity
133
+ if args.verbose >= 2:
134
+ level = logging.DEBUG
135
+ elif args.verbose == 1:
136
+ level = logging.INFO
137
+ else:
138
+ level = logging.WARNING
139
+ logging.basicConfig(level=level, format='%(levelname)s: %(message)s')
140
+
141
+ try:
142
+ size = _parse_size_arg(args.shape, args.size)
143
+ except Exception as e:
144
+ parser.error(str(e))
145
+
146
+ # Ensure rectangular mazes have odd dimensions to allow a valid path
147
+ if args.shape in ('rect', 'rectangle', 'grid', 'tri', 'triangle', 'triangular'):
148
+ if isinstance(size, (list, tuple)):
149
+ w, h = int(size[0]), int(size[1])
150
+ changed = False
151
+ if w % 2 == 0:
152
+ w += 1
153
+ changed = True
154
+ if h % 2 == 0:
155
+ h += 1
156
+ changed = True
157
+ if changed:
158
+ logging.info("Adjusted rectangular size to odd dimensions: %dx%d", w, h)
159
+ size = (w, h)
160
+ else:
161
+ s = int(size)
162
+ if s % 2 == 0:
163
+ s += 1
164
+ logging.info("Adjusted rectangular size to odd dimension: %d", s)
165
+ size = s
166
+
167
+ # determine output path: explicit output > out_dir auto-generated > None
168
+ output_path = None
169
+ if args.output:
170
+ output_path = args.output
171
+ elif args.out_dir:
172
+ outdir = Path(args.out_dir)
173
+ try:
174
+ outdir.mkdir(parents=True, exist_ok=True)
175
+ except Exception as e:
176
+ parser.error(f"Failed to create out-dir '{args.out_dir}': {e}")
177
+ ts = time.strftime('%Y%m%d_%H%M%S')
178
+ shape_key = args.shape.lower()
179
+ if shape_key in ('rect', 'rectangle', 'grid', 'tri', 'triangle', 'triangular'):
180
+ if isinstance(size, tuple):
181
+ w, h = size
182
+ if shape_key in ('tri', 'triangle', 'triangular'):
183
+ fname = f"maze_tri_{w}x{h}_{ts}.png"
184
+ else:
185
+ fname = f"maze_rect_{w}x{h}_{ts}.png"
186
+ else:
187
+ s = int(size)
188
+ if shape_key in ('tri', 'triangle', 'triangular'):
189
+ fname = f"maze_tri_{s}x{s}_{ts}.png"
190
+ else:
191
+ fname = f"maze_rect_{s}x{s}_{ts}.png"
192
+ else:
193
+ # hex
194
+ radius = int(size)
195
+ fname = f"maze_hex_r{radius}_{ts}.png"
196
+ # append seed to filename when a non-zero seed is provided
197
+ if args.seed:
198
+ name, ext = os.path.splitext(fname)
199
+ fname = f"{name}_s{args.seed}{ext}"
200
+ output_path = str(outdir / fname)
201
+ logging.info("Auto-generated output path: %s", output_path)
202
+
203
+ color_dict = {
204
+ 'wall': args.color_wall,
205
+ 'floor': args.color_floor,
206
+ 'start': args.color_start,
207
+ 'door': args.color_door,
208
+ 'path': args.color_path
209
+ }
210
+
211
+ maze, path = run_maze(shape=args.shape, size=size, cell=args.cell, use_iterative=args.iterative, draw=args.draw, output=output_path, show_solution=args.show_solution, seed=args.seed, color_dict=color_dict)
212
+
213
+ if args.show_solution:
214
+ if not path:
215
+ print("No solution path found.")
216
+ else:
217
+ try:
218
+ start = path[0]
219
+ end = path[-1]
220
+ print(f"Path length: {len(path)}")
221
+ print(f"Start: {start}")
222
+ print(f"End: {end}")
223
+ except Exception:
224
+ # Fallback: print minimal info
225
+ print(f"Path length: {len(path)}; start/end unavailable")
226
+
227
+ return maze, path
228
+
229
+
230
+ if __name__ == '__main__':
231
+ main()
utils/mazlib/draw.py ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+ import logging
3
+ from PIL import Image, ImageDraw
4
+
5
+
6
+ def draw_maze(maze, path, cell=20, save_path=None, show_doors=False, color_dict={'wall':'black', 'floor':'white', 'start':'green', 'door':'#B0B0B0', 'path':'red'}):
7
+ """
8
+ Draw the maze and an optional path using PIL.
9
+
10
+ Args:
11
+ maze (list[list[int]]): 2D grid with 1 for walls and 0 for open cells.
12
+ path (list[tuple[int,int]]): sequence of (x, y) coordinates forming a path through the maze.
13
+ cell (int): pixel size of one maze cell when rendering. Default is 20.
14
+ save_path (str|None): if given, save the rendered image to this file path; if None, show the image instead.
15
+ show_doors (bool): if True, draw thin grey outlines around each cell to indicate doors.
16
+ color_dict (dict): optional dict to specify colors for 'wall', 'floor', 'start', 'door', and 'path'.
17
+ """
18
+ h,w = len(maze),len(maze[0])
19
+ img = Image.new('RGB', (w*cell,h*cell), color_dict.get('floor', 'white'))
20
+ draw = ImageDraw.Draw(img)
21
+
22
+ # fill start cell (0,0) with green floor
23
+ draw.rectangle([0, 0, cell, cell], fill=color_dict.get('start', 'green'))
24
+ # Optional thin grey outlines for each cell (like hex show_doors)
25
+ if show_doors:
26
+ for y in range(h):
27
+ for x in range(w):
28
+ draw.rectangle(
29
+ [x * cell, y * cell, (x + 1) * cell, (y + 1) * cell],
30
+ outline=color_dict.get('door', '#B0B0B0'),
31
+ width=1
32
+ )
33
+
34
+ # Draw solid walls
35
+ for y in range(h):
36
+ for x in range(w):
37
+ if maze[y][x]:
38
+ draw.rectangle([x*cell,y*cell,(x+1)*cell,(y+1)*cell], fill=color_dict.get('wall', 'black'))
39
+
40
+ # Draw 2px exterior border, leaving an opening at the exit cell (w-1, h-1)
41
+ img_w, img_h = w * cell, h * cell
42
+ border_width = 4
43
+ draw.rectangle([0, 0, img_w - 1, img_h - 1], outline=color_dict.get('wall', 'black'), width=border_width)
44
+ ex0, ey0 = (w - 1) * cell, (h - 1) * cell
45
+ draw.line([(img_w - 1, ey0), (img_w - 1, ey0 + cell)], fill=color_dict.get('door', '#B0B0B0'), width=border_width + 2)
46
+ draw.line([(ex0, img_h - 1), (ex0 + cell, img_h - 1)], fill=color_dict.get('door', '#B0B0B0'), width=border_width + 2)
47
+
48
+ if path:
49
+ for i in range(len(path)-1):
50
+ x1,y1 = path[i]; x2,y2 = path[i+1]
51
+ draw.line([(x1*cell+cell//2,y1*cell+cell//2),(x2*cell+cell//2,y2*cell+cell//2)], fill=color_dict.get('path', 'red'), width=3)
52
+ if save_path:
53
+ try:
54
+ img.save(save_path)
55
+ logging.info("Saved rectangular maze image to %s", save_path)
56
+ except Exception:
57
+ logging.exception("Failed to save image to %s", save_path)
58
+ return img
59
+
60
+
61
+ def draw_hex_maze(maze, path, cell_size=45, rotation_degrees=0, save_path=None, show_doors=False, color_dict={'wall':'black', 'floor':'white', 'start':'green', 'door':'#B0B0B0', 'path':'red'}):
62
+ """
63
+ Draw a hex maze (walls only where no passage) and optional path using PIL.
64
+
65
+ Args:
66
+ maze (dict): mapping (q, r) -> list of (dq, dr) neighbor offsets.
67
+ path (list[tuple[int,int]]): sequence of axial coordinates to draw as red line.
68
+ cell_size (int): approximate pixel size of a hex cell.
69
+ save_path (str|None): if given, save image to file instead of showing.
70
+ rotation_degrees (float): rotation of hexagons in degrees (default 0 = flat-topped).
71
+ save_path (str|None): if provided, save the rendered image to this path; if None, show the image.
72
+ show_doors (bool): if True, draw thin grey outlines around each hex to show doors.
73
+ color_dict (dict): optional dict to specify colors for 'wall', 'floor', 'start', and 'door'.
74
+ """
75
+ rot = math.radians(rotation_degrees)
76
+ if not maze: return
77
+ r_max = max((max(abs(q),abs(r)) for q,r in maze), default=5)
78
+ w = int(3*(r_max+2)*cell_size)
79
+ h = int(math.sqrt(3)*2*(r_max+2)*cell_size)
80
+ img = Image.new('RGB', (w,h), color_dict.get('floor', 'white'))
81
+ d = ImageDraw.Draw(img)
82
+ dirs = [(1,0),(0,1),(-1,1),(-1,0),(0,-1),(1,-1)]
83
+
84
+ if show_doors:
85
+ for q,r in maze: # light grey outlines
86
+ cx = cell_size*1.5*q + w//2
87
+ cy = cell_size*math.sqrt(3)*(r + q/2) + h//2
88
+ pts = [(cx + cell_size*math.cos(rot + i*math.pi/3),
89
+ cy + cell_size*math.sin(rot + i*math.pi/3)) for i in range(6)]
90
+ d.polygon(pts, outline=color_dict.get('door', '#B0B0B0'), width=2)
91
+ # fill start hex (0,0) with green floor so the starting cell is visibly marked
92
+ if (0, 0) in maze:
93
+ cx = w//2
94
+ cy = h//2
95
+ start_pts = [(cx + cell_size*math.cos(rot + i*math.pi/3),
96
+ cy + cell_size*math.sin(rot + i*math.pi/3)) for i in range(6)]
97
+ d.polygon(start_pts, fill=color_dict.get('start', 'green'))
98
+
99
+ for q,r in maze: # black blocked walls
100
+ cx = cell_size*1.5*q + w//2
101
+ cy = cell_size*math.sqrt(3)*(r + q/2) + h//2
102
+ for i,(dq,dr) in enumerate(dirs):
103
+ if (dq,dr) not in maze.get((q,r),[]):
104
+ a = rot + i*math.pi/3
105
+ x1 = cx + cell_size*math.cos(a)
106
+ y1 = cy + cell_size*math.sin(a)
107
+ x2 = cx + cell_size*math.cos(a+math.pi/3)
108
+ y2 = cy + cell_size*math.sin(a+math.pi/3)
109
+ d.line([(x1,y1),(x2,y2)], fill=color_dict.get('wall', 'black'), width=6)
110
+
111
+ # force clear exit to outside on end cell (outer side with no neighbor)
112
+ end = max(maze, key=lambda p: abs(p[0])+abs(p[1])+abs(p[0]+p[1]))
113
+ cx = cell_size*1.5*end[0] + w//2
114
+ cy = cell_size*math.sqrt(3)*(end[1] + end[0]/2) + h//2
115
+ for i,(dq,dr) in enumerate(dirs):
116
+ neigh = (end[0]+dq, end[1]+dr)
117
+ if neigh not in maze: # outer side
118
+ a = rot + i*math.pi/3
119
+ x1 = cx + cell_size*math.cos(a)
120
+ y1 = cy + cell_size*math.sin(a)
121
+ x2 = cx + cell_size*math.cos(a+math.pi/3)
122
+ y2 = cy + cell_size*math.sin(a+math.pi/3)
123
+ d.line([(x1,y1),(x2,y2)], fill=color_dict.get('floor', 'white'), width=26)
124
+ break
125
+
126
+ if path:
127
+ pts = [(cell_size*1.5*q + w//2, cell_size*math.sqrt(3)*(r + q/2) + h//2) for q,r in path]
128
+ d.line(pts, fill=color_dict.get('path', 'red'), width=7)
129
+
130
+ if save_path:
131
+ try:
132
+ img.save(save_path)
133
+ logging.info("Saved hex maze image to %s", save_path)
134
+ except Exception:
135
+ logging.exception("Failed to save image to %s", save_path)
136
+ return img
137
+
138
+
139
+ def draw_tri_maze(maze, path, cell=40, save_path=None, show_doors=False, color_dict={'wall':'black', 'floor':'white', 'start':'green', 'door':'#B0B0B0', 'path':'red'}):
140
+ """
141
+ Draw a triangular-grid maze using PIL.
142
+
143
+ Maze format expected:
144
+ maze (dict): mapping (x, y) -> list of (dx, dy) neighbor offsets that are open.
145
+ Each cell is an equilateral triangle. Coordinates are grid indices (x:col, y:row).
146
+
147
+ Args:
148
+ maze (dict): triangle adjacency mapping.
149
+ path (list[tuple[int,int]]): list of (x,y) cells forming a path.
150
+ cell (int): side length of each triangle in pixels.
151
+ save_path (str|None): save to file if provided, otherwise show.
152
+ show_doors (bool): if True draw thin grey outlines around each triangle.
153
+ color_dict (dict): optional dict to specify colors for 'wall', 'floor', 'start', 'door', and 'path'.
154
+ """
155
+ if not maze:
156
+ return
157
+ # determine bounds
158
+ xs = [p[0] for p in maze]
159
+ ys = [p[1] for p in maze]
160
+ min_x, max_x = min(xs), max(xs)
161
+ min_y, max_y = min(ys), max(ys)
162
+ w = max_x - min_x + 1
163
+ h = max_y - min_y + 1
164
+
165
+ side = cell
166
+ h_tri = side * math.sqrt(3) / 2.0
167
+ offset_x = side # margin
168
+ offset_y = side
169
+
170
+ img_w = int(offset_x*2 + (w-1) * (side/2.0) + side + 0.5)
171
+ img_h = int(offset_y*2 + h * h_tri + 0.5)
172
+ img = Image.new('RGB', (img_w, img_h), color_dict.get('floor', 'white'))
173
+ d = ImageDraw.Draw(img)
174
+
175
+ def cell_vertices(x,y):
176
+ cx = offset_x + (x - min_x) * (side/2.0)
177
+ cy = offset_y + (y - min_y) * h_tri
178
+ up = ((x + y) % 2 == 0)
179
+ if up:
180
+ v0 = (cx, cy)
181
+ v1 = (cx - side/2.0, cy + h_tri)
182
+ v2 = (cx + side/2.0, cy + h_tri)
183
+ else:
184
+ v0 = (cx, cy + h_tri)
185
+ v1 = (cx - side/2.0, cy)
186
+ v2 = (cx + side/2.0, cy)
187
+ return up, v0, v1, v2
188
+
189
+ # fill start triangle (0,0) with green floor so the starting cell is visibly marked
190
+ if (0, 0) in maze:
191
+ _, v0_0, v1_0, v2_0 = cell_vertices(0, 0)
192
+ d.polygon([v0_0, v1_0, v2_0], fill=color_dict.get('start', 'green'))
193
+
194
+ # optional thin grey outlines for each triangle
195
+ if show_doors:
196
+ for (x,y) in maze:
197
+ up, v0, v1, v2 = cell_vertices(x,y)
198
+ pts = [v0, v1, v2]
199
+ d.polygon(pts, outline=color_dict.get('door', '#B0B0B0'), width=1)
200
+
201
+ # draw black walls where no passage
202
+ for (x,y) in maze:
203
+ up, v0, v1, v2 = cell_vertices(x,y)
204
+ # map direction offsets to edge vertex pairs
205
+ if up:
206
+ edge_map = {
207
+ (-1,0): (v0, v1), # left
208
+ (1,0): (v0, v2), # right
209
+ (0,1): (v1, v2), # down/base
210
+ }
211
+ else:
212
+ edge_map = {
213
+ (-1,0): (v1, v0), # left
214
+ (1,0): (v2, v0), # right
215
+ (0,-1): (v1, v2), # up/base
216
+ }
217
+ for off,verts in edge_map.items():
218
+ if off not in maze.get((x,y), []):
219
+ d.line([verts[0], verts[1]], fill=color_dict.get('wall', 'black'), width=6)
220
+
221
+ # force clear exit on end cell (outer side with no neighbor)
222
+ # choose end as max by x+y
223
+ end = max(maze, key=lambda p: p[0] + p[1])
224
+ ex, ey = end
225
+ up, v0, v1, v2 = cell_vertices(ex, ey)
226
+ if up:
227
+ dir_order = [(-1,0),(1,0),(0,1)]
228
+ edge_order = [(v0,v1),(v0,v2),(v1,v2)]
229
+ else:
230
+ dir_order = [(-1,0),(1,0),(0,-1)]
231
+ edge_order = [(v1,v0),(v2,v0),(v1,v2)]
232
+ for off,edge in zip(dir_order, edge_order):
233
+ neigh = (ex + off[0], ey + off[1])
234
+ if neigh not in maze:
235
+ d.line([edge[0], edge[1]], fill=color_dict.get('floor', 'white'), width=26)
236
+ break
237
+
238
+ # draw path if provided (connect centroids)
239
+ if path:
240
+ pts = []
241
+ for x,y in path:
242
+ up, v0, v1, v2 = cell_vertices(x,y)
243
+ # centroid average of vertices
244
+ cx = (v0[0] + v1[0] + v2[0]) / 3.0
245
+ cy = (v0[1] + v1[1] + v2[1]) / 3.0
246
+ pts.append((cx, cy))
247
+ d.line(pts, fill=color_dict.get('path', 'red'), width=7)
248
+
249
+ if save_path:
250
+ try:
251
+ img.save(save_path)
252
+ logging.info("Saved triangular maze image to %s", save_path)
253
+ except Exception:
254
+ logging.exception("Failed to save tri image to %s", save_path)
255
+ return img
utils/mazlib/grid.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import random
2
+ from collections import deque
3
+
4
+
5
+ def generate_maze(w, h, seed=0):
6
+ """
7
+ Generate a randomized perfect maze using depth-first search.
8
+
9
+ Args:
10
+ w (int): width of the maze in cells (number of columns).
11
+ h (int): height of the maze in cells (number of rows).
12
+
13
+ Returns:
14
+ list[list[int]]: 2D grid representing the maze where 1 indicates a wall
15
+ and 0 indicates an open cell. The start is at (0,0) and the goal is at (w-1,h-1).
16
+ """
17
+ maze = [[1]*w for _ in range(h)]
18
+ rng = random.Random(seed) if seed else None
19
+
20
+ def dfs(x, y):
21
+ maze[y][x] = 0
22
+ dirs = [(0,2),(2,0),(0,-2),(-2,0)]
23
+ (rng.shuffle(dirs) if rng is not None else random.shuffle(dirs))
24
+ for dx,dy in dirs:
25
+ nx,ny = x+dx,y+dy
26
+ if 0<=nx<w and 0<=ny<h and maze[ny][nx]:
27
+ maze[y+dy//2][x+dx//2] = 0
28
+ dfs(nx,ny)
29
+ # occasional extra random connection (10%) to introduce loops and increase difficulty
30
+ if (rng.random() if rng is not None else random.random()) < 0.1:
31
+ extra_dirs = [d for d in dirs if d != (dx,dy)]
32
+ (rng.shuffle(extra_dirs) if rng is not None else random.shuffle(extra_dirs))
33
+ for edx, edy in extra_dirs:
34
+ enx, eny = x+edx, y+edy
35
+ # ensure within bounds and target already open
36
+ if 0<=enx<w and 0<=eny<h and maze[eny][enx]==0:
37
+ # open the wall between (x,y) and (enx,eny) if it's closed
38
+ wx, wy = x+edx//2, y+edy//2
39
+ if maze[wy][wx]:
40
+ maze[wy][wx] = 0
41
+ break
42
+
43
+ dfs(0,0)
44
+ maze[0][0] = maze[h-1][w-1] = 0
45
+ # ensure starting cell has one extra open neighbour if possible (not an external wall)
46
+ dirs = [(0,2),(2,0),(0,-2),(-2,0)]
47
+ random.shuffle(dirs)
48
+ for dx,dy in dirs:
49
+ nx,ny = dx,dy
50
+ if 0<=nx<w and 0<=ny<h and maze[ny][nx]==1:
51
+ # open the intermediate wall and the target cell
52
+ maze[dy//2][dx//2] = 0
53
+ maze[ny][nx] = 0
54
+ break
55
+ return maze
56
+
57
+
58
+ def generate_maze_iterative(w, h, seed=0):
59
+ """
60
+ Generate a randomized perfect maze using an explicit stack (iterative DFS).
61
+
62
+ Args:
63
+ w (int): width of the maze in cells (number of columns).
64
+ h (int): height of the maze in cells (number of rows).
65
+
66
+ Returns:
67
+ list[list[int]]: 2D grid representing the maze where 1 indicates a wall
68
+ and 0 indicates an open cell.
69
+ """
70
+ maze = [[1]*w for _ in range(h)]
71
+ rng = random.Random(seed) if seed else None
72
+ stack = [(0, 0)]
73
+ maze[0][0] = 0
74
+ dirs = [(0,2),(2,0),(0,-2),(-2,0)]
75
+ while stack:
76
+ x,y = stack[-1]
77
+ (rng.shuffle(dirs) if rng is not None else random.shuffle(dirs))
78
+ for dx,dy in dirs:
79
+ nx,ny = x+dx,y+dy
80
+ if 0<=nx<w and 0<=ny<h and maze[ny][nx]:
81
+ maze[y+dy//2][x+dx//2] = 0
82
+ maze[ny][nx] = 0
83
+ stack.append((nx,ny))
84
+ # occasional extra random connection (10%) to introduce loops and increase difficulty
85
+ if (rng.random() if rng is not None else random.random()) < 0.1:
86
+ extra_dirs = [d for d in dirs if d != (dx,dy)]
87
+ (rng.shuffle(extra_dirs) if rng is not None else random.shuffle(extra_dirs))
88
+ for edx, edy in extra_dirs:
89
+ enx, eny = x+edx, y+edy
90
+ if 0<=enx<w and 0<=eny<h and maze[eny][enx]==0:
91
+ wx, wy = x+edx//2, y+edy//2
92
+ if maze[wy][wx]:
93
+ maze[wy][wx] = 0
94
+ break
95
+ break
96
+ else:
97
+ stack.pop()
98
+ maze[0][0] = maze[h-1][w-1] = 0
99
+ # ensure starting cell has one extra open neighbour if possible (not an external wall)
100
+ dirs = [(0,2),(2,0),(0,-2),(-2,0)]
101
+ (rng.shuffle(dirs) if rng is not None else random.shuffle(dirs))
102
+ for dx,dy in dirs:
103
+ nx,ny = 0+dx, 0+dy
104
+ if 0<=nx<w and 0<=ny<h and maze[ny][nx]==1:
105
+ # open the intermediate wall and the target cell
106
+ maze[dy//2][dx//2] = 0
107
+ maze[ny][nx] = 0
108
+ break
109
+ return maze
110
+
111
+
112
+ def solve_maze(maze):
113
+ """
114
+ Solve a maze grid using breadth-first search to produce the shortest path.
115
+
116
+ Args:
117
+ maze (list[list[int]]): 2D grid where 1 indicates a wall and 0 indicates open space.
118
+
119
+ Returns:
120
+ list[tuple[int,int]]: list of (x, y) coordinates from start (0,0) to goal (w-1,h-1).
121
+ """
122
+ h,w = len(maze),len(maze[0])
123
+ q = deque([(0,0)])
124
+ parent = {(0,0):None}
125
+ while q:
126
+ x,y = q.popleft()
127
+ if (x,y)==(w-1,h-1):
128
+ break
129
+ for dx,dy in [(0,1),(1,0),(0,-1),(-1,0)]:
130
+ nx,ny = x+dx,y+dy
131
+ if 0<=nx<w and 0<=ny<h and maze[ny][nx]==0 and (nx,ny) not in parent:
132
+ parent[(nx,ny)] = (x,y)
133
+ q.append((nx,ny))
134
+ path = []
135
+ at = (w-1,h-1)
136
+ while at:
137
+ path.append(at)
138
+ at = parent.get(at)
139
+ return path[::-1]
utils/mazlib/hex.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import random
2
+ from collections import deque
3
+
4
+
5
+ def generate_hex_maze(radius, seed=0):
6
+ """Backwards-compatible wrapper that uses the iterative implementation."""
7
+ return generate_hex_maze_iterative(radius, seed=seed)
8
+
9
+
10
+ def generate_hex_maze_iterative(radius, seed=0):
11
+ """
12
+ Generate a hexagonal maze using an explicit stack (iterative DFS) on axial coords.
13
+
14
+ Args:
15
+ radius (int): maximum radius (in hex steps) from the origin to include.
16
+
17
+ Returns:
18
+ dict: mapping (q, r) -> list of (dq, dr) neighbor offsets for open edges.
19
+ """
20
+ maze = {}
21
+ visited = set()
22
+ stack = [(0, 0)]
23
+ visited.add((0, 0))
24
+ dirs = [(1,0),(0,1),(-1,1),(-1,0),(0,-1),(1,-1)]
25
+ rng = random.Random(seed) if seed else None
26
+ while stack:
27
+ q,r = stack[-1]
28
+ directions = dirs[:] # ← fixed: copy before shuffle
29
+ (rng.shuffle(directions) if rng is not None else random.shuffle(directions))
30
+ moved = False
31
+ for dq,dr in directions:
32
+ nq,nr = q+dq,r+dr
33
+ if (nq,nr) not in visited and max(abs(nq),abs(nr),abs(nq+nr)) <= radius:
34
+ visited.add((nq,nr))
35
+ stack.append((nq,nr))
36
+ maze.setdefault((q,r),[]).append((dq,dr))
37
+ maze.setdefault((nq,nr),[]).append((-dq,-dr))
38
+ # occasional extra random connection (10%) to introduce loops and increase difficulty
39
+ if (rng.random() if rng is not None else random.random()) < 0.1:
40
+ # prefer connecting to an already visited neighbor so we create a loop
41
+ extra_dirs = [d for d in dirs if d != (dq,dr)]
42
+ (rng.shuffle(extra_dirs) if rng is not None else random.shuffle(extra_dirs))
43
+ for edq,edr in extra_dirs:
44
+ enq,enr = q+edq, r+edr
45
+ # only connect to already visited neighbours to avoid creating isolated cells
46
+ if (enq,enr) in visited and (edq,edr) not in maze.get((q,r), []):
47
+ maze.setdefault((q,r),[]).append((edq,edr))
48
+ maze.setdefault((enq,enr),[]).append((-edq,-edr))
49
+ break
50
+ moved = True
51
+ break
52
+ if not moved:
53
+ stack.pop()
54
+ # Ensure the starting hex has two additional neighbor offsets if possible.
55
+ # This makes the maze entry/exit from the center more open and increases difficulty.
56
+ # (If there are not enough valid neighbours within the radius, add as many as possible.)
57
+ if (0, 0) not in maze:
58
+ maze.setdefault((0, 0), [])
59
+ # choose up to two directions not already open from the start
60
+ available = [d for d in dirs if d not in maze.get((0, 0), []) and max(abs(d[0]), abs(d[1]), abs(d[0] + d[1])) <= radius]
61
+ (rng.shuffle(available) if rng is not None else random.shuffle(available))
62
+ for dq, dr in available[:2]:
63
+ nq, nr = dq, dr
64
+ # add reciprocal entries even if the neighbour wasn't previously in the maze
65
+ if (dq, dr) not in maze.get((0, 0), []):
66
+ maze.setdefault((0, 0), []).append((dq, dr))
67
+ maze.setdefault((nq, nr), []).append((-dq, -dr))
68
+
69
+ return maze
70
+
71
+
72
+ def solve_hex_maze(maze):
73
+ """
74
+ Solve a hex maze (produced by generate_hex_maze) using breadth-first search.
75
+
76
+ Args:
77
+ maze (dict): mapping (q, r) -> list of (dq, dr) neighbor offsets.
78
+
79
+ Returns:
80
+ list[tuple[int,int]]: sequence of axial coordinates from the origin to the
81
+ farthest cell (heuristic goal) in the generated maze. Returns empty list
82
+ for an empty maze.
83
+ """
84
+ if not maze:
85
+ return []
86
+ start = (0,0)
87
+ end = max(maze, key=lambda p: abs(p[0])+abs(p[1])+abs(p[0]+p[1]))
88
+ queue = deque([start])
89
+ parent = {start: None}
90
+ while queue:
91
+ curr = queue.popleft()
92
+ if curr == end:
93
+ break
94
+ for dq,dr in maze.get(curr,[]):
95
+ neigh = (curr[0]+dq, curr[1]+dr)
96
+ if neigh not in parent:
97
+ parent[neigh] = curr
98
+ queue.append(neigh)
99
+ path = []
100
+ at = end
101
+ while at:
102
+ path.append(at)
103
+ at = parent.get(at)
104
+ return path[::-1]
utils/mazlib/requirements.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ Pillow>=9.0
utils/mazlib/tri.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Triangle-grid maze helpers.
2
+
3
+ This module implements a simple triangular-grid maze generator (randomized
4
+ depth-first search), a BFS solver and a drawing helper that renders the
5
+ triangular tiling with walls and optional thin grey outlines for doors.
6
+
7
+ Maze representation:
8
+ dict mapping (x,y) -> list of (dx,dy) neighbor offsets that are open.
9
+
10
+ Coordinates:
11
+ x: column index, 0..w-1
12
+ y: row index, 0..h-1
13
+
14
+ Triangle orientation alternates with parity: up when (x+y) % 2 == 0,
15
+ otherwise down.
16
+ """
17
+ import random
18
+ from collections import deque
19
+ from .draw import draw_tri_maze as _draw_tri
20
+
21
+
22
+ def _neighbor_offsets(x, y, w, h):
23
+ """Return valid neighbor offsets for triangle at (x,y) based on orientation."""
24
+ up = ((x + y) % 2 == 0)
25
+ if up:
26
+ candidates = [(-1, 0), (1, 0), (0, 1)]
27
+ else:
28
+ candidates = [(-1, 0), (1, 0), (0, -1)]
29
+ valid = []
30
+ for dx, dy in candidates:
31
+ nx, ny = x + dx, y + dy
32
+ if 0 <= nx < w and 0 <= ny < h:
33
+ valid.append((dx, dy))
34
+ return valid
35
+
36
+
37
+ def generate_tri_maze(w, h=None, seed=0):
38
+ """Generate a triangular maze using randomized DFS.
39
+
40
+ Args:
41
+ w (int): number of columns.
42
+ h (int|None): number of rows (if None, equals w).
43
+
44
+ Returns:
45
+ dict: mapping (x,y) -> list of neighbor offsets (dx,dy) that are open.
46
+ """
47
+ if h is None:
48
+ h = w
49
+ # initialize empty adjacency
50
+ maze = {(x, y): [] for x in range(w) for y in range(h)}
51
+ rng = random.Random(seed) if seed else None
52
+ visited = set()
53
+
54
+ stack = [(0, 0)]
55
+ visited.add((0, 0))
56
+
57
+ while stack:
58
+ x, y = stack[-1]
59
+ offs = _neighbor_offsets(x, y, w, h)
60
+ # filter to unvisited neighbours
61
+ nbrs = [(dx, dy) for dx, dy in offs if (x + dx, y + dy) not in visited]
62
+ if nbrs:
63
+ dx, dy = (rng.choice(nbrs) if rng is not None else random.choice(nbrs))
64
+ nx, ny = x + dx, y + dy
65
+ # carve passage both ways (store offsets)
66
+ maze[(x, y)].append((dx, dy))
67
+ maze[(nx, ny)].append((-dx, -dy))
68
+ visited.add((nx, ny))
69
+ stack.append((nx, ny))
70
+ # occasional extra random connection (10%) to introduce loops and increase difficulty
71
+ if (rng.random() if rng is not None else random.random()) < 0.1:
72
+ extra_offs = [o for o in offs if o != (dx, dy)]
73
+ (rng.shuffle(extra_offs) if rng is not None else random.shuffle(extra_offs))
74
+ for edx, edy in extra_offs:
75
+ enx, eny = x + edx, y + edy
76
+ # connect only to already visited neighbours to avoid isolating cells
77
+ if (enx, eny) in visited and (edx, edy) not in maze.get((x, y), []):
78
+ maze[(x, y)].append((edx, edy))
79
+ maze[(enx, eny)].append((-edx, -edy))
80
+ break
81
+ else:
82
+ stack.pop()
83
+ # ensure the starting triangle (0,0) has one additional neighbour offset if possible
84
+ start_offs = _neighbor_offsets(0, 0, w, h)
85
+ candidates = [o for o in start_offs if o not in maze.get((0, 0), [])]
86
+ if candidates:
87
+ edx, edy = (rng.choice(candidates) if rng is not None else random.choice(candidates))
88
+ nx, ny = 0 + edx, 0 + edy
89
+ if (edx, edy) not in maze.get((0, 0), []):
90
+ maze[(0, 0)].append((edx, edy))
91
+ maze[(nx, ny)].append((-edx, -edy))
92
+
93
+ return maze
94
+
95
+
96
+ def generate_tri_maze_iterative(w, h=None, seed=0):
97
+ """Alias for generate_tri_maze; generation is already iterative.
98
+
99
+ Kept for API symmetry with other generators.
100
+ """
101
+ return generate_tri_maze(w, h, seed=seed)
102
+
103
+
104
+ def solve_tri_maze(maze):
105
+ """Solve a triangular maze returning a path from start to end.
106
+
107
+ Start is assumed at (0,0). End is chosen as the cell with the maximum
108
+ x+y (bottom-right-like) so it aligns with the drawing and CLI examples.
109
+
110
+ Returns:
111
+ list[(x,y)]: path from start to end (inclusive) or empty list if none.
112
+ """
113
+ if not maze:
114
+ return []
115
+ start = (0, 0)
116
+ end = max(maze, key=lambda p: p[0] + p[1])
117
+
118
+ q = deque([start])
119
+ parent = {start: None}
120
+ while q:
121
+ cur = q.popleft()
122
+ if cur == end:
123
+ break
124
+ x, y = cur
125
+ for dx, dy in maze.get((x, y), []):
126
+ nx, ny = x + dx, y + dy
127
+ if (nx, ny) not in parent:
128
+ parent[(nx, ny)] = (x, y)
129
+ q.append((nx, ny))
130
+ if end not in parent:
131
+ return []
132
+ # reconstruct path
133
+ path = []
134
+ at = end
135
+ while at is not None:
136
+ path.append(at)
137
+ at = parent.get(at)
138
+ return path[::-1]
utils/storage.md CHANGED
@@ -1,4 +1,4 @@
1
- # Storage Module (`modules/storage.py`) Usage Guide
2
 
3
  The `storage.py` module provides helper functions for:
4
  - Generating permalinks for 3D viewer projects.
@@ -12,7 +12,7 @@ The `storage.py` module provides helper functions for:
12
  ### 1. `generate_permalink(valid_files, base_url_external, permalink_viewer_url="surn-3d-viewer.hf.space")`
13
  - **Purpose:**
14
  Given a list of file paths, it looks for exactly one model file (with an extension defined in `model_extensions`) and exactly two image files (extensions defined in `image_extensions`). If the criteria are met, it returns a permalink URL built from the base URL and query parameters.
15
- - **Usage Example:**from modules.storage import generate_permalink
16
 
17
  valid_files = [
18
  "models/3d_model.glb",
@@ -26,7 +26,7 @@ if permalink:
26
  ### 2. `generate_permalink_from_urls(model_url, hm_url, img_url, permalink_viewer_url="surn-3d-viewer.hf.space")`
27
  - **Purpose:**
28
  Constructs a permalink URL by combining individual URLs for a 3D model (`model_url`), height map (`hm_url`), and image (`img_url`) into a single URL with corresponding query parameters.
29
- - **Usage Example:**from modules.storage import generate_permalink_from_urls
30
 
31
  model_url = "https://example.com/model.glb"
32
  hm_url = "https://example.com/heightmap.png"
@@ -48,7 +48,7 @@ print("Generated Permalink:", permalink)
48
  - If no valid files are provided, it returns an empty list `[]` (this case should ideally also return the dictionary with empty/None values for consistency, but currently returns `[]` as per the code).
49
  - **Usage Example:**
50
 
51
- **a. Uploading with permalink creation:**from modules.storage import upload_files_to_repo
52
 
53
  files_for_permalink = [
54
  "local/path/to/model.glb",
@@ -75,7 +75,7 @@ elif upload_result: # Check if list is not empty
75
  print(f" Response: {res}, Link: {link}")
76
  else:
77
  print("No files uploaded or error occurred.")
78
- **b. Uploading without permalink creation (or if conditions for permalink are not met):**from modules.storage import upload_files_to_repo
79
 
80
  files_individual = [
81
  "local/path/to/another_model.obj",
@@ -113,8 +113,8 @@ If both short URL ID and full URL are provided, it checks consistency and either
113
  - **Returns:** A tuple `(status_message, result_url)`, where `status_message` indicates the outcome (e.g., `"success_retrieved_full"`, `"created_short"`) and `result_url` is the relevant URL (full or short ID).
114
  - **Usage Examples:**
115
 
116
- **a. Convert a full URL into a short URL ID:**from modules.storage import gen_full_url
117
- from modules.constants import HF_REPO_ID, SHORTENER_JSON_FILE # Assuming these are defined
118
 
119
  full_permalink = "https://surn-3d-viewer.hf.space/?3d=https%3A%2F%2Fexample.com%2Fmodel.glb&hm=https%3A%2F%2Fexample.com%2Fheightmap.png&image=https%3A%2F%2Fexample.com%2Fsource.png"
120
 
@@ -130,8 +130,67 @@ if status == "created_short" or status == "success_retrieved_short":
130
  # permalink_viewer_url = "surn-3d-viewer.hf.space" # Or from constants
131
  # shareable_short_url = f"https://{permalink_viewer_url}/?sid={short_id}"
132
  # print("Shareable Short URL:", shareable_short_url)
133
- **b. Retrieve the full URL from a short URL ID:**from modules.storage import gen_full_url
134
- from modules.constants import HF_REPO_ID, SHORTENER_JSON_FILE # Assuming these are defined
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
 
136
  short_id_to_lookup = "aBcDeFg1" # Example short URL ID
137
 
@@ -144,8 +203,9 @@ print("Status:", status)
144
  if status == "success_retrieved_full":
145
  print("Retrieved Full URL:", retrieved_full_url)
146
  ## Notes
147
- - **Authentication:** All functions that interact with Hugging Face Hub use the HF API token defined as `HF_API_TOKEN` in `modules/constants.py`. Ensure this environment variable is correctly set.
148
- - **Constants:** Functions like `gen_full_url` and `upload_files_to_repo` (when creating short links) rely on `HF_REPO_ID` and `SHORTENER_JSON_FILE` from `modules/constants.py` for the URL shortening feature.
 
149
  - **File Types:** Only files with extensions included in `upload_file_types` (a combination of `model_extensions` and `image_extensions` from `modules/constants.py`) are processed by `upload_files_to_repo`.
150
  - **Repository Configuration:** When using URL shortening and file uploads, ensure that the specified Hugging Face repository (e.g., defined by `HF_REPO_ID`) exists and that you have write permissions.
151
  - **Temporary Directory:** `upload_files_to_repo` temporarily copies files to a local directory (configured by `TMPDIR` in `modules/constants.py`) before uploading.
 
1
+ # Storage Module (`utils/storage.py`) Usage Guide
2
 
3
  The `storage.py` module provides helper functions for:
4
  - Generating permalinks for 3D viewer projects.
 
12
  ### 1. `generate_permalink(valid_files, base_url_external, permalink_viewer_url="surn-3d-viewer.hf.space")`
13
  - **Purpose:**
14
  Given a list of file paths, it looks for exactly one model file (with an extension defined in `model_extensions`) and exactly two image files (extensions defined in `image_extensions`). If the criteria are met, it returns a permalink URL built from the base URL and query parameters.
15
+ - **Usage Example:**from utils.storage import generate_permalink
16
 
17
  valid_files = [
18
  "models/3d_model.glb",
 
26
  ### 2. `generate_permalink_from_urls(model_url, hm_url, img_url, permalink_viewer_url="surn-3d-viewer.hf.space")`
27
  - **Purpose:**
28
  Constructs a permalink URL by combining individual URLs for a 3D model (`model_url`), height map (`hm_url`), and image (`img_url`) into a single URL with corresponding query parameters.
29
+ - **Usage Example:**from utils.storage import generate_permalink_from_urls
30
 
31
  model_url = "https://example.com/model.glb"
32
  hm_url = "https://example.com/heightmap.png"
 
48
  - If no valid files are provided, it returns an empty list `[]` (this case should ideally also return the dictionary with empty/None values for consistency, but currently returns `[]` as per the code).
49
  - **Usage Example:**
50
 
51
+ **a. Uploading with permalink creation:**from utils.storage import upload_files_to_repo
52
 
53
  files_for_permalink = [
54
  "local/path/to/model.glb",
 
75
  print(f" Response: {res}, Link: {link}")
76
  else:
77
  print("No files uploaded or error occurred.")
78
+ **b. Uploading without permalink creation (or if conditions for permalink are not met):**from utils.storage import upload_files_to_repo
79
 
80
  files_individual = [
81
  "local/path/to/another_model.obj",
 
113
  - **Returns:** A tuple `(status_message, result_url)`, where `status_message` indicates the outcome (e.g., `"success_retrieved_full"`, `"created_short"`) and `result_url` is the relevant URL (full or short ID).
114
  - **Usage Examples:**
115
 
116
+ **a. Convert a full URL into a short URL ID:**from utils.storage import gen_full_url
117
+ from utils.constants import HF_REPO_ID, SHORTENER_JSON_FILE # Assuming these are defined
118
 
119
  full_permalink = "https://surn-3d-viewer.hf.space/?3d=https%3A%2F%2Fexample.com%2Fmodel.glb&hm=https%3A%2F%2Fexample.com%2Fheightmap.png&image=https%3A%2F%2Fexample.com%2Fsource.png"
120
 
 
130
  # permalink_viewer_url = "surn-3d-viewer.hf.space" # Or from constants
131
  # shareable_short_url = f"https://{permalink_viewer_url}/?sid={short_id}"
132
  # print("Shareable Short URL:", shareable_short_url)
133
+ **b. Retrieve the full URL from a short URL ID:**from utils.storage import gen_full_url
134
+ from utils.constants import HF_REPO_ID, SHORTENER_JSON_FILE # Assuming these are defined
135
+
136
+ ## 📁 Repository Folder Listing Functions
137
+
138
+ ### 5. `_list_repo_folders(repo_id, path_prefix, repo_type="dataset")`
139
+ - **Purpose:**
140
+ List folder names under a given path in a HuggingFace repository. Enables folder-based discovery without index files.
141
+ - **Parameters:**
142
+ - `repo_id` (str): The repository ID on Hugging Face
143
+ - `path_prefix` (str): The path prefix to list folders under
144
+ - `repo_type` (str): Repository type. Default is `"dataset"`.
145
+ - **Returns:** `List[str]` - List of folder names found under the path_prefix.
146
+ - **Usage Example:**
147
+ ```python
148
+ from utils.storage import _list_repo_folders
149
+
150
+ # List all date folders in daily leaderboards
151
+ folders = _list_repo_folders("Surn/Wrdler-Data", "games/leaderboards/daily")
152
+ print("Available dates:", folders)
153
+ ```
154
+
155
+ ### 6. `_list_repo_files_in_folder(repo_id, folder_path, repo_type="dataset")`
156
+ - **Purpose:**
157
+ List file names directly under a folder in a HuggingFace repository.
158
+ - **Parameters:**
159
+ - `repo_id` (str): The repository ID on Hugging Face
160
+ - `folder_path` (str): The folder path to list files under
161
+ - `repo_type` (str): Repository type. Default is `"dataset"`.
162
+ - **Returns:** `List[str]` - List of file names found directly in the folder.
163
+ - **Usage Example:**
164
+ ```python
165
+ from utils.storage import _list_repo_files_in_folder
166
+
167
+ files = _list_repo_files_in_folder(
168
+ "Surn/Wrdler-Data",
169
+ "games/leaderboards/daily/2025-01-27/classic-classic-0",
170
+ )
171
+ print("Files:", files)
172
+ ```
173
+
174
+ ## 🔑 Cryptographic Key Management Functions
175
+
176
+ ### 7. `store_issuer_keypair(issuer_id, public_key, private_key, repo_id=None)`
177
+ - **Purpose:**
178
+ Store cryptographic keys for an issuer in a private Hugging Face repository. Private keys are encrypted before storage.
179
+ - **⚠️ IMPORTANT:** This requires a PRIVATE Hugging Face repository. Do not use this with public repositories.
180
+ - **Returns:** `bool` - True if keys were stored successfully, False otherwise.
181
+
182
+ ### 8. `get_issuer_keypair(issuer_id, repo_id=None)`
183
+ - **Purpose:**
184
+ Retrieve and decrypt stored cryptographic keys for an issuer.
185
+ - **Returns:** `Tuple[Optional[str], Optional[str]]` - (public_key, private_key) or (None, None) if not found.
186
+
187
+ ### 9. `get_verification_methods_registry(repo_id=None)`
188
+ - **Purpose:**
189
+ Retrieve the global verification methods registry containing all registered issuer public keys.
190
+
191
+ ### 10. `list_issuer_ids(repo_id=None)`
192
+ - **Purpose:**
193
+ List all issuer IDs that have stored keys in the repository.
194
 
195
  short_id_to_lookup = "aBcDeFg1" # Example short URL ID
196
 
 
203
  if status == "success_retrieved_full":
204
  print("Retrieved Full URL:", retrieved_full_url)
205
  ## Notes
206
+ - **Authentication:** All functions that interact with Hugging Face Hub use the HF API token defined as `HF_API_TOKEN` in `utils/constants.py`. Ensure this environment variable is correctly set.
207
+ - **Constants:** Functions like `gen_full_url` and `upload_files_to_repo` (when creating short links) rely on `HF_REPO_ID` and `SHORTENER_JSON_FILE` from `utils/constants.py` for the URL shortening feature.
208
+ - **🔐 Private Repository Requirement:** Key management functions require a PRIVATE Hugging Face repository to ensure the security of stored encrypted private keys. Never use these functions with public repositories.
209
  - **File Types:** Only files with extensions included in `upload_file_types` (a combination of `model_extensions` and `image_extensions` from `modules/constants.py`) are processed by `upload_files_to_repo`.
210
  - **Repository Configuration:** When using URL shortening and file uploads, ensure that the specified Hugging Face repository (e.g., defined by `HF_REPO_ID`) exists and that you have write permissions.
211
  - **Temporary Directory:** `upload_files_to_repo` temporarily copies files to a local directory (configured by `TMPDIR` in `modules/constants.py`) before uploading.
utils/storage.py CHANGED
@@ -1,15 +1,42 @@
1
- # utils/storage.py
2
- __version__ = "0.1.1" # Added version
 
 
 
 
 
 
 
 
 
 
 
3
  import os
4
  import urllib.parse
5
  import tempfile
6
  import shutil
7
  import json
8
  import base64
 
 
9
  from huggingface_hub import login, upload_folder, hf_hub_download, HfApi
10
  from huggingface_hub.utils import RepositoryNotFoundError, EntryNotFoundError
11
- from utils.constants import HF_API_TOKEN, upload_file_types, model_extensions, image_extensions, audio_extensions, video_extensions, HF_REPO_ID, SHORTENER_JSON_FILE
12
- from typing import Any, Dict, List, Tuple, Union
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
  # see storage.md for detailed information about the storage module and its functions.
15
 
@@ -23,6 +50,7 @@ def generate_permalink(valid_files, base_url_external, permalink_viewer_url="sur
23
  images_links = []
24
  audio_links = []
25
  video_links = []
 
26
  for f in valid_files:
27
  filename = os.path.basename(f)
28
  ext = os.path.splitext(filename)[1].lower()
@@ -35,6 +63,8 @@ def generate_permalink(valid_files, base_url_external, permalink_viewer_url="sur
35
  audio_links.append(f"{base_url_external}/{filename}")
36
  elif ext in video_extensions:
37
  video_links.append(f"{base_url_external}/{filename}")
 
 
38
  if model_link and len(images_links) == 2:
39
  # Construct a permalink to the viewer project with query parameters.
40
  permalink_viewer_url = f"https://{permalink_viewer_url}/"
@@ -94,8 +124,14 @@ def upload_files_to_repo(
94
  Otherwise:
95
  list: A list of tuples (response, permalink) for each file.
96
  """
 
 
97
  # Log in using the HF API token.
98
- login(token=HF_API_TOKEN)
 
 
 
 
99
 
100
  valid_files = []
101
  permalink_short = None
@@ -131,13 +167,17 @@ def upload_files_to_repo(
131
 
132
  # Batch upload all files in the temporary folder.
133
  # Files will be uploaded under the folder (path_in_repo) given by folder_name.
134
- response = upload_folder(
135
- folder_path=temp_dir,
136
- repo_id=repo_id,
137
- repo_type=repo_type,
138
- path_in_repo=folder_name,
139
- commit_message="Batch upload files"
140
- )
 
 
 
 
141
 
142
  # Construct external URLs for each uploaded file.
143
  base_url_external = f"https://huggingface.co/datasets/{repo_id}/resolve/main/{folder_name}"
@@ -160,8 +200,8 @@ def upload_files_to_repo(
160
  if status in ["created_short", "success_retrieved_short", "exists_match"]:
161
  permalink_short = f"https://{permalink_viewer_url}/?sid={short_id}"
162
  else: # Shortening failed or conflict not resolved to a usable short_id
163
- permalink_short = None
164
- print(f"URL shortening status: {status} for {permalink}")
165
 
166
  return {
167
  "response": response,
@@ -178,6 +218,33 @@ def upload_files_to_repo(
178
  # Otherwise, return individual tuples for each file.
179
  return [(response, link) for link in individual_links]
180
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
  def _generate_short_id(length=8):
182
  """Generates a random base64 URL-safe string."""
183
  return base64.urlsafe_b64encode(os.urandom(length * 2))[:length].decode('utf-8')
@@ -197,16 +264,16 @@ def _get_json_from_repo(repo_id, json_file_name, repo_type="dataset"):
197
  os.remove(json_path) # Clean up downloaded file
198
  return data
199
  except RepositoryNotFoundError:
200
- print(f"Repository {repo_id} not found.")
201
  return []
202
  except EntryNotFoundError:
203
- print(f"JSON file {json_file_name} not found in {repo_id}. Initializing with empty list.")
204
  return []
205
  except json.JSONDecodeError:
206
- print(f"Error decoding JSON from {json_file_name}. Returning empty list.")
207
  return []
208
  except Exception as e:
209
- print(f"An unexpected error occurred while fetching {json_file_name}: {e}")
210
  return []
211
 
212
  def _upload_json_to_repo(data, repo_id, json_file_name, repo_type="dataset"):
@@ -232,7 +299,7 @@ def _upload_json_to_repo(data, repo_id, json_file_name, repo_type="dataset"):
232
  os.remove(tmp_file_path) # Clean up temporary file
233
  return True
234
  except Exception as e:
235
- print(f"Failed to upload {json_file_name} to {repo_id}: {e}")
236
  if 'tmp_file_path' in locals() and os.path.exists(tmp_file_path):
237
  os.remove(tmp_file_path) # Ensure cleanup on error too
238
  return False
@@ -325,3 +392,174 @@ def gen_full_url(short_url=None, full_url=None, repo_id=None, repo_type="dataset
325
  return "error_upload", None
326
 
327
  return "error_unhandled_case", None # Should not be reached
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hugging Face Hub storage helpers.
2
+
3
+ This module is a port of the BattleWords `modules/storage.py` implementation.
4
+ It supports:
5
+ - Batch uploading files via `upload_folder`
6
+ - Generating permalinks to a 3D viewer
7
+ - URL-shortener mapping stored in a JSON file in a HF dataset repo
8
+ - Listing repo folders/files
9
+ - Optional issuer keypair storage (requires PRIVATE repo; demo encryption)
10
+ """
11
+
12
+ __version__ = "0.1.6"
13
+
14
  import os
15
  import urllib.parse
16
  import tempfile
17
  import shutil
18
  import json
19
  import base64
20
+ import logging
21
+ from datetime import datetime, timezone
22
  from huggingface_hub import login, upload_folder, hf_hub_download, HfApi
23
  from huggingface_hub.utils import RepositoryNotFoundError, EntryNotFoundError
24
+ from utils.constants import (
25
+ HF_API_TOKEN,
26
+ upload_file_types,
27
+ model_extensions,
28
+ image_extensions,
29
+ audio_extensions,
30
+ video_extensions,
31
+ doc_extensions,
32
+ HF_REPO_ID,
33
+ SHORTENER_JSON_FILE,
34
+ )
35
+ from typing import Any, Dict, List, Tuple, Union, Optional
36
+
37
+ # Configure logging
38
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
39
+ logger = logging.getLogger(__name__)
40
 
41
  # see storage.md for detailed information about the storage module and its functions.
42
 
 
50
  images_links = []
51
  audio_links = []
52
  video_links = []
53
+ doc_links = []
54
  for f in valid_files:
55
  filename = os.path.basename(f)
56
  ext = os.path.splitext(filename)[1].lower()
 
63
  audio_links.append(f"{base_url_external}/{filename}")
64
  elif ext in video_extensions:
65
  video_links.append(f"{base_url_external}/{filename}")
66
+ elif ext in doc_extensions:
67
+ doc_links.append(f"{base_url_external}/{filename}")
68
  if model_link and len(images_links) == 2:
69
  # Construct a permalink to the viewer project with query parameters.
70
  permalink_viewer_url = f"https://{permalink_viewer_url}/"
 
124
  Otherwise:
125
  list: A list of tuples (response, permalink) for each file.
126
  """
127
+ logger.info(f"Starting batch upload to repository: {repo_id}")
128
+
129
  # Log in using the HF API token.
130
+ try:
131
+ login(token=HF_API_TOKEN)
132
+ except Exception as e:
133
+ logger.error(f"Authentication failed: {e}")
134
+ return {"response": "Authentication failed", "permalink": None, "short_permalink": None} if create_permalink else []
135
 
136
  valid_files = []
137
  permalink_short = None
 
167
 
168
  # Batch upload all files in the temporary folder.
169
  # Files will be uploaded under the folder (path_in_repo) given by folder_name.
170
+ try:
171
+ response = upload_folder(
172
+ folder_path=temp_dir,
173
+ repo_id=repo_id,
174
+ repo_type=repo_type,
175
+ path_in_repo=folder_name,
176
+ commit_message="Batch upload files",
177
+ )
178
+ except Exception as e:
179
+ logger.error(f"Batch upload failed: {e}")
180
+ return {"response": f"Upload failed: {e}", "permalink": None, "short_permalink": None} if create_permalink else []
181
 
182
  # Construct external URLs for each uploaded file.
183
  base_url_external = f"https://huggingface.co/datasets/{repo_id}/resolve/main/{folder_name}"
 
200
  if status in ["created_short", "success_retrieved_short", "exists_match"]:
201
  permalink_short = f"https://{permalink_viewer_url}/?sid={short_id}"
202
  else: # Shortening failed or conflict not resolved to a usable short_id
203
+ permalink_short = None
204
+ logger.warning(f"URL shortening status: {status} for {permalink}")
205
 
206
  return {
207
  "response": response,
 
218
  # Otherwise, return individual tuples for each file.
219
  return [(response, link) for link in individual_links]
220
 
221
+
222
+ def _get_files_from_repo(repo_id, file_name, repo_type="dataset"):
223
+ """Downloads a file from the repo. File must be in upload_file_types."""
224
+ filename = os.path.basename(file_name)
225
+ ext = os.path.splitext(file_name)[1].lower()
226
+ if ext not in upload_file_types:
227
+ logger.error(f"File {filename} with extension {ext} is not allowed for upload.")
228
+ return None
229
+ try:
230
+ login(token=HF_API_TOKEN)
231
+ file_path = hf_hub_download(
232
+ repo_id=repo_id,
233
+ filename=file_name,
234
+ repo_type=repo_type,
235
+ token=HF_API_TOKEN,
236
+ )
237
+ return file_path or None
238
+ except RepositoryNotFoundError:
239
+ logger.warning(f"Repository {repo_id} not found.")
240
+ return None
241
+ except EntryNotFoundError:
242
+ logger.warning(f"file {file_name} not found in {repo_id}.")
243
+ return None
244
+ except Exception as e:
245
+ logger.error(f"Error fetching {file_name} from {repo_id}: {e}")
246
+ return None
247
+
248
  def _generate_short_id(length=8):
249
  """Generates a random base64 URL-safe string."""
250
  return base64.urlsafe_b64encode(os.urandom(length * 2))[:length].decode('utf-8')
 
264
  os.remove(json_path) # Clean up downloaded file
265
  return data
266
  except RepositoryNotFoundError:
267
+ logger.warning(f"Repository {repo_id} not found.")
268
  return []
269
  except EntryNotFoundError:
270
+ logger.warning(f"JSON file {json_file_name} not found in {repo_id}. Initializing with empty list.")
271
  return []
272
  except json.JSONDecodeError:
273
+ logger.error(f"Error decoding JSON from {json_file_name}. Returning empty list.")
274
  return []
275
  except Exception as e:
276
+ logger.error(f"An unexpected error occurred while fetching {json_file_name}: {e}")
277
  return []
278
 
279
  def _upload_json_to_repo(data, repo_id, json_file_name, repo_type="dataset"):
 
299
  os.remove(tmp_file_path) # Clean up temporary file
300
  return True
301
  except Exception as e:
302
+ logger.error(f"Failed to upload {json_file_name} to {repo_id}: {e}")
303
  if 'tmp_file_path' in locals() and os.path.exists(tmp_file_path):
304
  os.remove(tmp_file_path) # Ensure cleanup on error too
305
  return False
 
392
  return "error_upload", None
393
 
394
  return "error_unhandled_case", None # Should not be reached
395
+
396
+
397
+ def _encrypt_private_key(private_key: str, password: str = None) -> str:
398
+ """Basic XOR encryption for demo purposes only."""
399
+ if not password:
400
+ password = "default_encryption_key"
401
+ encrypted_bytes = [ord(char) ^ ord(password[i % len(password)]) for i, char in enumerate(private_key)]
402
+ return base64.b64encode(bytes(encrypted_bytes)).decode("utf-8")
403
+
404
+
405
+ def _decrypt_private_key(encrypted_private_key: str, password: str = None) -> str:
406
+ """Basic XOR decryption for demo purposes only."""
407
+ if not password:
408
+ password = "default_encryption_key"
409
+ encrypted_data = base64.b64decode(encrypted_private_key)
410
+ decrypted_chars = [chr(byte ^ ord(password[i % len(password)])) for i, byte in enumerate(encrypted_data)]
411
+ return "".join(decrypted_chars)
412
+
413
+
414
+ def store_issuer_keypair(issuer_id: str, public_key: str, private_key: str, repo_id: str = None) -> bool:
415
+ """Store issuer keypair in HF dataset repo (PRIVATE repo strongly recommended)."""
416
+ if not issuer_id or not public_key or not private_key:
417
+ raise ValueError("issuer_id, public_key, and private_key are required")
418
+
419
+ if not repo_id:
420
+ repo_id = HF_REPO_ID
421
+
422
+ safe_issuer_id = issuer_id.replace("https://", "").replace("http://", "").replace("/", "_").replace(":", "_")
423
+
424
+ encrypted_private_key = _encrypt_private_key(private_key)
425
+ private_key_data = {
426
+ "issuer_id": issuer_id,
427
+ "encrypted_private_key": encrypted_private_key,
428
+ "key_type": "Ed25519VerificationKey2020",
429
+ "created_at": datetime.now(timezone.utc).isoformat(),
430
+ "encryption_method": "basic_xor",
431
+ }
432
+ public_key_data = {
433
+ "issuer_id": issuer_id,
434
+ "public_key": public_key,
435
+ "key_type": "Ed25519VerificationKey2020",
436
+ "created_at": datetime.now(timezone.utc).isoformat(),
437
+ }
438
+
439
+ private_key_path = f"keys/issuers/{safe_issuer_id}/private_key.json"
440
+ public_key_path = f"keys/issuers/{safe_issuer_id}/public_key.json"
441
+
442
+ private_ok = _upload_json_to_repo(private_key_data, repo_id, private_key_path, "dataset")
443
+ public_ok = _upload_json_to_repo(public_key_data, repo_id, public_key_path, "dataset")
444
+ if private_ok and public_ok:
445
+ _update_verification_methods_registry(issuer_id, safe_issuer_id, public_key, repo_id)
446
+ return private_ok and public_ok
447
+
448
+
449
+ def get_issuer_keypair(issuer_id: str, repo_id: str = None) -> Tuple[Optional[str], Optional[str]]:
450
+ """Retrieve stored issuer keypair from HF dataset repo."""
451
+ if not issuer_id:
452
+ raise ValueError("issuer_id is required")
453
+
454
+ if not repo_id:
455
+ repo_id = HF_REPO_ID
456
+
457
+ safe_issuer_id = issuer_id.replace("https://", "").replace("http://", "").replace("/", "_").replace(":", "_")
458
+
459
+ public_key_path = f"keys/issuers/{safe_issuer_id}/public_key.json"
460
+ private_key_path = f"keys/issuers/{safe_issuer_id}/private_key.json"
461
+ public_key_data = _get_json_from_repo(repo_id, public_key_path, "dataset")
462
+ private_key_data = _get_json_from_repo(repo_id, private_key_path, "dataset")
463
+ if not public_key_data or not private_key_data:
464
+ return None, None
465
+
466
+ encrypted_private_key = private_key_data.get("encrypted_private_key")
467
+ if not encrypted_private_key:
468
+ return None, None
469
+
470
+ return public_key_data.get("public_key"), _decrypt_private_key(encrypted_private_key)
471
+
472
+
473
+ def _update_verification_methods_registry(issuer_id: str, safe_issuer_id: str, public_key: str, repo_id: str):
474
+ try:
475
+ registry_path = "keys/global/verification_methods.json"
476
+ registry_data = _get_json_from_repo(repo_id, registry_path, "dataset")
477
+
478
+ if not registry_data:
479
+ registry_data = {"verification_methods": []}
480
+
481
+ existing_entry = None
482
+ for i, method in enumerate(registry_data.get("verification_methods", [])):
483
+ if method.get("issuer_id") == issuer_id:
484
+ existing_entry = i
485
+ break
486
+
487
+ verification_method = {
488
+ "issuer_id": issuer_id,
489
+ "safe_issuer_id": safe_issuer_id,
490
+ "public_key": public_key,
491
+ "key_type": "Ed25519VerificationKey2020",
492
+ "updated_at": datetime.now(timezone.utc).isoformat(),
493
+ }
494
+
495
+ if existing_entry is not None:
496
+ registry_data["verification_methods"][existing_entry] = verification_method
497
+ else:
498
+ registry_data["verification_methods"].append(verification_method)
499
+
500
+ _upload_json_to_repo(registry_data, repo_id, registry_path, "dataset")
501
+ except Exception as e:
502
+ logger.error(f"Error updating verification methods registry: {e}")
503
+
504
+
505
+ def get_verification_methods_registry(repo_id: str = None) -> Dict[str, Any]:
506
+ if not repo_id:
507
+ repo_id = HF_REPO_ID
508
+ registry_path = "keys/global/verification_methods.json"
509
+ registry_data = _get_json_from_repo(repo_id, registry_path, "dataset")
510
+ return registry_data if registry_data else {"verification_methods": []}
511
+
512
+
513
+ def list_issuer_ids(repo_id: str = None) -> List[str]:
514
+ if not repo_id:
515
+ repo_id = HF_REPO_ID
516
+ registry = get_verification_methods_registry(repo_id)
517
+ return [method["issuer_id"] for method in registry.get("verification_methods", [])]
518
+
519
+
520
+ def _list_repo_folders(repo_id: str, path_prefix: str, repo_type: str = "dataset") -> List[str]:
521
+ try:
522
+ login(token=HF_API_TOKEN)
523
+ api = HfApi()
524
+ all_files = api.list_repo_files(repo_id=repo_id, repo_type=repo_type, token=HF_API_TOKEN)
525
+
526
+ if path_prefix and not path_prefix.endswith("/"):
527
+ path_prefix = path_prefix + "/"
528
+
529
+ folders = set()
530
+ for file_path in all_files:
531
+ if file_path.startswith(path_prefix):
532
+ relative_path = file_path[len(path_prefix) :]
533
+ if "/" in relative_path:
534
+ folders.add(relative_path.split("/")[0])
535
+ return sorted(list(folders))
536
+ except RepositoryNotFoundError:
537
+ logger.warning(f"Repository {repo_id} not found.")
538
+ return []
539
+ except Exception as e:
540
+ logger.error(f"Error listing folders in {repo_id}/{path_prefix}: {e}")
541
+ return []
542
+
543
+
544
+ def _list_repo_files_in_folder(repo_id: str, folder_path: str, repo_type: str = "dataset") -> List[str]:
545
+ try:
546
+ login(token=HF_API_TOKEN)
547
+ api = HfApi()
548
+ all_files = api.list_repo_files(repo_id=repo_id, repo_type=repo_type, token=HF_API_TOKEN)
549
+
550
+ if folder_path and not folder_path.endswith("/"):
551
+ folder_path = folder_path + "/"
552
+
553
+ files: List[str] = []
554
+ for file_path in all_files:
555
+ if file_path.startswith(folder_path):
556
+ relative_path = file_path[len(folder_path) :]
557
+ if "/" not in relative_path and relative_path:
558
+ files.append(relative_path)
559
+ return sorted(files)
560
+ except RepositoryNotFoundError:
561
+ logger.warning(f"Repository {repo_id} not found.")
562
+ return []
563
+ except Exception as e:
564
+ logger.error(f"Error listing files in {repo_id}/{folder_path}: {e}")
565
+ return []