about summary refs log tree commit diff stats
diff options
context:
space:
mode:
authorAndinus <andinus@nand.sh>2022-01-12 12:42:38 +0530
committerAndinus <andinus@nand.sh>2022-01-12 12:42:38 +0530
commit25f65f212c210638d15d73c0c8dfe1fc08bd96a2 (patch)
tree48a98fcd1b0440465eca8e3d8684a306605c3b28
parent59d0cd4fbb98cffa28120714b87008573777f429 (diff)
downloadoctans-25f65f212c210638d15d73c0c8dfe1fc08bd96a2.tar.gz
Add visualize feature using Cairo
This takes parts from Fornax: https://github.com/andinus/fornax

Each step is visualized and a video solution is generated.
-rw-r--r--lib/Octans/CLI.rakumod34
-rw-r--r--lib/Octans/GenerateFrame.rakumod63
-rw-r--r--lib/Octans/Hex2RGB.rakumod10
-rw-r--r--lib/Octans/WordSearch.rakumod17
4 files changed, 118 insertions, 6 deletions
diff --git a/lib/Octans/CLI.rakumod b/lib/Octans/CLI.rakumod
index f3501df..4d94b69 100644
--- a/lib/Octans/CLI.rakumod
+++ b/lib/Octans/CLI.rakumod
@@ -1,6 +1,7 @@
 use Octans::Puzzle;
 use Octans::WordSearch;
 use Octans::Puzzle::Get;
+use Octans::GenerateFrame;
 
 proto MAIN(|) is export { unless so @*ARGS { say $*USAGE; exit }; {*} }
 
@@ -9,6 +10,7 @@ multi sub MAIN(
     Str :$dict = (%?RESOURCES<mwords/354984si.ngl> //
                   "/usr/share/dict/words").Str, #= dictionary file
     Int :$length = 7, #= minimum word length (default: 7)
+    Bool :$visualize, #= produces a video output
     Bool :$verbose, #= increase verbosity
 ) is export {
     # @dict holds the sorted dictionary. Only consider words >= 7
@@ -31,18 +33,40 @@ multi sub MAIN(
         "    $_".say for $puzzle.grids;
     }
 
+    constant $SIDE = 128;
+    my Int %meta = :rows($puzzle.grids.elems), :cols($puzzle.grids[0].elems);
+    my Int %canvas = :width(%meta<cols> * $SIDE), :height(%meta<rows> * $SIDE);
+
+    my IO() $output = "%s/octans-%s".sprintf(
+        '/tmp', ('a'...'z', 'A'...'Z', 0...9).roll(8).join
+    );
+    if $visualize {
+        mkdir $output;
+        put "Output: $output";
+        die "Output directory doesn't exist" unless $output.d;
+    }
+
+    my Int $iter = 0;
     # start-pos block loops over each starting position.
     start-pos: for $puzzle.gray-squares -> $pos {
         # gather all the words that word-search finds starting from
         # $pos.
         word: for gather word-search(
-            @dict, $puzzle.grids, $pos[0], $pos[1],
+            @dict, $puzzle.grids, $pos[0], $pos[1], :visualize
         ) -> (
             # word-search returns the word along with @visited which
             # holds the list of all grids that were visited when the
             # word was found.
             $word, @visited
         ) {
+            if $visualize {
+                generate-frame(
+                    :%canvas, :side($SIDE), :puzzle($puzzle.grids), :@visited, :%meta,
+                    :out("%s/%08d.png".sprintf: $output, $iter++), :found($word ne "")
+                );
+                next word;
+            }
+
             printf "%s$word\n", $verbose ?? "\n" !! "";
             next word unless so $verbose;
 
@@ -61,6 +85,14 @@ multi sub MAIN(
             }
         }
     }
+
+    if $visualize {
+        put "Creating a slideshow.";
+        my Str $log-level = $verbose ?? "info" !! "error";
+        run «ffmpeg -loglevel "$log-level" -r 1.5 -i "$output/\%08d.png"
+                    -vf 'tpad=stop_mode=clone:stop_duration=1'
+                    -vcodec libx264 -crf 24 -pix_fmt yuv420p "$output/solution.mp4"»;
+    }
 }
 
 multi sub MAIN(
diff --git a/lib/Octans/GenerateFrame.rakumod b/lib/Octans/GenerateFrame.rakumod
new file mode 100644
index 0000000..ba7fc5b
--- /dev/null
+++ b/lib/Octans/GenerateFrame.rakumod
@@ -0,0 +1,63 @@
+# This module has been adapted from Fornax::GenerateFrame.
+
+use Cairo;
+use Octans::Hex2RGB;
+
+# Colors.
+constant %C = (
+    bg-main => "#ffffff",
+
+    red-subtle-bg => "#f2b0a2",
+    blue-subtle-bg => "#b5d0ff",
+    cyan-subtle-bg => "#c0efff",
+    green-subtle-bg => "#aecf90",
+
+    fg-main => "#000000",
+
+    fg-special-cold => "#093060",
+    fg-special-warm => "#5d3026",
+    fg-special-mild => "#184034",
+    fg-special-calm => "#61284f",
+).map: {.key => hex2rgb(.value)};
+
+enum IterStatus <Walking Blocked Completed>;
+
+sub generate-frame(
+    :%canvas, :$out, :$side, :@puzzle, :@visited, :%meta, :$found
+) is export {
+    given Cairo::Image.create(
+        Cairo::FORMAT_ARGB32, %canvas<width>, %canvas<height>
+    ) {
+        given Cairo::Context.new($_) {
+            # Paint the entire canvas white.
+            .rgb: |%C<bg-main>;
+            .rectangle(0, 0, %canvas<width>, %canvas<height>);
+            .fill;
+
+            # This seems to be slower than creating an intermediate
+            # variable and assigning from that. Difference is not much
+            # so we'll ignore it.
+            for ^%meta<rows> X ^%meta<cols>  -> ($r, $c) {
+                my Int @target = $c * $side, $r * $side,
+                                 $side, $side;
+                .rectangle: |@target;
+
+                if @visited[$r][$c] {
+                    .rgba: |%C<cyan-subtle-bg>, 0.72;
+                    .rgba: |%C<green-subtle-bg>, 0.84 if $found;
+                    .fill :preserve;
+                }
+
+                .select_font_face("Mono", Cairo::FONT_SLANT_NORMAL, Cairo::FONT_WEIGHT_NORMAL);
+                .set_font_size(72.0);
+                .move_to($c * $side + 32, ($r + 1) * $side - 28);
+
+                .rgb: |%C<fg-main>;
+                .show_text: @puzzle[$r][$c].uc;
+                .stroke;
+            }
+        }
+        .write_png($out);
+        .finish;
+    }
+}
diff --git a/lib/Octans/Hex2RGB.rakumod b/lib/Octans/Hex2RGB.rakumod
new file mode 100644
index 0000000..72cb848
--- /dev/null
+++ b/lib/Octans/Hex2RGB.rakumod
@@ -0,0 +1,10 @@
+# This module has been adapted from Fornax::Hex2RGB.
+
+#| Takes hex value and returns RGB equivalent.
+sub hex2rgb(Str $hex --> List) is export {
+    # Skip the first character, group each by 2 and parse as base 16.
+    # Divide by 255 to return value between 0, 1.
+    $hex.comb.skip.rotor(2).map(
+        *.join.parse-base(16) / 255
+    )>>.Rat
+}
diff --git a/lib/Octans/WordSearch.rakumod b/lib/Octans/WordSearch.rakumod
index 23487dc..73d05cd 100644
--- a/lib/Octans/WordSearch.rakumod
+++ b/lib/Octans/WordSearch.rakumod
@@ -1,9 +1,9 @@
 use Octans::Neighbors;
 use Octans::RangeSearch;
 
-# word-search walks the given grid & tries to find words in the
-# dictionary. It walks in Depth-First manner (lookup Depth-First
-# search).
+#| word-search walks the given grid & tries to find words in the
+#| dictionary. It walks in Depth-First manner (lookup Depth-First
+#| search).
 sub word-search(
     # @dict holds the dictionary. @puzzle holds the puzzle.
     @dict, @puzzle,
@@ -18,12 +18,19 @@ sub word-search(
     Int $y, Int $x, $str? = @puzzle[$y][$x],
 
     # @visited holds the positions that we've already visited.
-    @visited? is copy --> List
+    @visited? is copy,
+
+    # If true then every iteration of walking is returned.
+    Bool :$visualize,
+
+    --> List
 ) is export {
     # If @visited was not passed then mark the given cell as visited
     # because it's the cell we're starting at.
     @visited[$y][$x] = True unless @visited;
 
+    take "", @visited if $visualize;
+
     # neighbor block loops over the neighbors of $y, $x.
     neighbor: for neighbors(@puzzle, $y, $x).List -> $pos {
         # Move on to next neighbor if we've already visited this one.
@@ -53,7 +60,7 @@ sub word-search(
                     # subset of words that start with "a", so keeping
                     # this in mind we pass the output of last
                     # range-starts-with (@list).
-                    @list, @puzzle, $pos[0], $pos[1], $word, @visited
+                    @list, @puzzle, $pos[0], $pos[1], $word, @visited, :$visualize,
                 );
             }
         }