about summary refs log tree commit diff stats
diff options
context:
space:
mode:
-rw-r--r--.gitignore2
-rw-r--r--META6.json9
-rw-r--r--README126
-rw-r--r--README.org13
-rw-r--r--lib/Octans/CLI.rakumod35
-rw-r--r--lib/Octans/GenerateFrame.rakumod61
-rw-r--r--lib/Octans/Hex2RGB.rakumod10
-rw-r--r--lib/Octans/WordSearch.rakumod17
-rw-r--r--t/00-basic.rakutest12
-rw-r--r--t/01-hex2rgb.rakutest8
10 files changed, 240 insertions, 53 deletions
diff --git a/.gitignore b/.gitignore
index 4a5e4c7..76e4cdc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,3 @@
 lib/.precomp
+.precomp/
+.log/
diff --git a/META6.json b/META6.json
index 02d8328..a329c04 100644
--- a/META6.json
+++ b/META6.json
@@ -1,7 +1,7 @@
 {
     "name" : "octans",
     "auth" : "zef:andinus",
-    "version" : "0.2.1",
+    "version" : "0.2.3",
     "description" : "Octans is a program to solve Algot's Wordplay (Wordsearch) puzzles",
     "authors" : [ "Andinus <andinus@nand.sh>" ],
     "license" : "ISC",
@@ -12,10 +12,13 @@
         "Octans::Puzzle" : "lib/Octans/Puzzle.rakumod",
         "Octans::Puzzle::Get" : "lib/Octans/Puzzle/Get.rakumod",
         "Octans::RangeSearch" : "lib/Octans/RangeSearch.rakumod",
-        "Octans::WordSearch" : "lib/Octans/WordSearch.rakumod"
+        "Octans::WordSearch" : "lib/Octans/WordSearch.rakumod",
+        "Octans::Hex2RGB" : "lib/Octans/Hex2RGB.rakumod",
+        "Octans::GenerateFrame" : "lib/Octans/GenerateFrame.rakumod"
     },
     "depends" : [
-        "WWW:ver<1.005006+>:auth<github:raku-community-modules>"
+        "WWW:ver<1.005006+>:auth<github:raku-community-modules>",
+        "Cairo:ver<0.2.7+>"
     ],
     "build-depends" : [ ],
     "test-depends" : [ ],
diff --git a/README b/README
index b190176..e4fc224 100644
--- a/README
+++ b/README
@@ -10,10 +10,28 @@
 Table of Contents
 ─────────────────
 
-Demo
-Installation
-Documentation
-News
+1. Demo
+2. Installation
+.. 1. Release
+.. 2. From Source
+..... 1. Without `git'
+..... 2. With `git'
+3. Documentation
+.. 1. Implementation
+.. 2. Options
+..... 1. dict
+..... 2. visualize
+..... 3. length
+..... 4. path
+..... 5. verbose
+4. News
+.. 1. v0.2.3 - 2022-01-12
+.. 2. v0.2.0 - 2021-03-04
+.. 3. v0.1.4 - 2021-02-19
+.. 4. v0.1.3 - 2021-01-24
+.. 5. v0.1.2 - 2021-01-20
+.. 6. v0.1.1 - 2021-01-20
+.. 7. v0.1.0 - 2021-01-19
 
 
 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -23,13 +41,15 @@ News
 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 
 
-Demo
-════
+1 Demo
+══════
 
   This was recorded with `asciinema'.
 
   [https://asciinema.org/a/385500.png]
 
+  ⁃ Octans v0.2.3: [Visualized Output]
+
   ⁃ Octans v0.1.0 - finds 10 solutions to a puzzle:
     <https://asciinema.org/a/385598>
   ⁃ Octans v0.1.0: <https://asciinema.org/a/385500>
@@ -40,9 +60,12 @@ Demo
 
 [https://asciinema.org/a/385500.png] <https://asciinema.org/a/385500>
 
+[Visualized Output]
+<https://andinus.unfla.me/resources/projects/octans/v0.2.3_2022-01-12_sample_input.mp4>
+
 
-Installation
-════════════
+2 Installation
+══════════════
 
   Octans is released to CPAN, you can get it from there or install it
   from source. In any case, `zef' is required to install the
@@ -56,23 +79,23 @@ Installation
     branch.
 
 
-Release
-───────
+2.1 Release
+───────────
 
   1. Run `zef install octans'.
 
   Octans should be installed, try running `octans --version' to confirm.
 
 
-From Source
-───────────
+2.2 From Source
+───────────────
 
   You can either download the release archive generated by cgit/GitHub
   or clone the project if you have `git' installed.
 
 
-Without `git'
-╌╌╌╌╌╌╌╌╌╌╌╌╌
+2.2.1 Without `git'
+╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
 
   1. Download the release:
      • <https://git.tilde.institute/andinus/octans>
@@ -81,8 +104,8 @@ Without `git'
   3. Run `zef install .' in source directory.
 
 
-With `git'
-╌╌╌╌╌╌╌╌╌╌
+2.2.2 With `git'
+╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
 
   All commits will be signed by my [PGP Key].
 
@@ -100,11 +123,11 @@ With `git'
 <https://andinus.nand.sh/static/D9AE4AEEE1F1B3598E81D9DFB67D55D482A799FD.asc>
 
 
-Documentation
-═════════════
+3 Documentation
+═══════════════
 
-Implementation
-──────────────
+3.1 Implementation
+──────────────────
 
   Initially it went over the list of words & checked if they exist in
   the grid. This was very slow.
@@ -124,25 +147,31 @@ Implementation
   from my AoC (Advent of Code) 2020's day-11 solution.
 
 
-Options
-───────
+3.2 Options
+───────────
 
-dict
-╌╌╌╌
+3.2.1 dict
+╌╌╌╌╌╌╌╌╌╌
 
   Octans's default dictionary file is `/usr/share/dict/words', use
   `--dict' flag to change the dictionary. The words in dictionary must
   be seperated by a newline (`\n') & sorted alphabetically.
 
 
-length
-╌╌╌╌╌╌
+3.2.2 visualize
+╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
+
+  Generates a video solution for the puzzle.
+
+
+3.2.3 length
+╌╌╌╌╌╌╌╌╌╌╌╌
 
   Minimum word length to look for. Default is 7.
 
 
-path
-╌╌╌╌
+3.2.4 path
+╌╌╌╌╌╌╌╌╌╌
 
   The path to be passed must be a *readable file* or an *url* in either
   format:
@@ -154,17 +183,26 @@ path
     <https://mastodon.art/@Algot/105333136907848390>
 
 
-verbose
-╌╌╌╌╌╌╌
+3.2.5 verbose
+╌╌╌╌╌╌╌╌╌╌╌╌╌
 
   This will increase verbosity.
 
 
-News
-════
+4 News
+══════
+
+4.1 v0.2.3 - 2022-01-12
+───────────────────────
+
+  ⁃ Add visualize option.
+
+    `--visualize' now generates a video solution for the puzzle using
+    Cairo and ffmpeg.
+
 
-v0.2.0 - 2021-03-04
-───────────────────
+4.2 v0.2.0 - 2021-03-04
+───────────────────────
 
   ⁃ Removed `sample' option
 
@@ -193,8 +231,8 @@ v0.2.0 - 2021-03-04
           reset for each starting position.
 
 
-v0.1.4 - 2021-02-19
-───────────────────
+4.3 v0.1.4 - 2021-02-19
+───────────────────────
 
   ⁃ Fixed the puzzle parsing regex
 
@@ -202,26 +240,26 @@ v0.1.4 - 2021-02-19
     <https://mastodon.art/@Algot/105690195742318751>.
 
 
-v0.1.3 - 2021-01-24
-───────────────────
+4.4 v0.1.3 - 2021-01-24
+───────────────────────
 
   ⁃ Added an option to specify minimum word length.
 
 
-v0.1.2 - 2021-01-20
-───────────────────
+4.5 v0.1.2 - 2021-01-20
+───────────────────────
 
   ⁃ Input puzzle can now be of any size & not restricted to 4x4 grid.
 
 
-v0.1.1 - 2021-01-20
-───────────────────
+4.6 v0.1.1 - 2021-01-20
+───────────────────────
 
   ⁃ Read puzzle from a file.
 
 
-v0.1.0 - 2021-01-19
-───────────────────
+4.7 v0.1.0 - 2021-01-19
+───────────────────────
 
   ⁃ Improved performance by using a better algorithm to find words in
     the grid.
diff --git a/README.org b/README.org
index 1ed72f5..d29f7ec 100644
--- a/README.org
+++ b/README.org
@@ -13,6 +13,8 @@ This was recorded with ~asciinema~.
 
 [[https://asciinema.org/a/385500][https://asciinema.org/a/385500.png]]
 
++ Octans v0.2.3: [[https://andinus.unfla.me/resources/projects/octans/v0.2.3_2022-01-12_sample_input.mp4][Visualized Output]]
+
 + Octans v0.1.0 - finds 10 solutions to a puzzle:
   https://asciinema.org/a/385598
 + Octans v0.1.0: https://asciinema.org/a/385500
@@ -92,6 +94,10 @@ Octans's default dictionary file is =/usr/share/dict/words=, use ~--dict~
 flag to change the dictionary. The words in dictionary must be seperated
 by a newline (=\n=) & sorted alphabetically.
 
+*** visualize
+
+Generates a video solution for the puzzle.
+
 *** length
 
 Minimum word length to look for. Default is 7.
@@ -113,6 +119,13 @@ This will increase verbosity.
 
 * News
 
+** v0.2.3 - 2022-01-12
+
++ Add visualize option.
+
+  ~--visualize~ now generates a video solution for the puzzle using Cairo
+  and ffmpeg.
+
 ** v0.2.0 - 2021-03-04
 
 + Removed ~sample~ option
diff --git a/lib/Octans/CLI.rakumod b/lib/Octans/CLI.rakumod
index f3501df..d090eb3 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,15 @@ 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"»;
+        put "Video Solution: $output/solution.mp4";
+    }
 }
 
 multi sub MAIN(
diff --git a/lib/Octans/GenerateFrame.rakumod b/lib/Octans/GenerateFrame.rakumod
new file mode 100644
index 0000000..9fe5754
--- /dev/null
+++ b/lib/Octans/GenerateFrame.rakumod
@@ -0,0 +1,61 @@
+# 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)};
+
+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,
                 );
             }
         }
diff --git a/t/00-basic.rakutest b/t/00-basic.rakutest
new file mode 100644
index 0000000..0eb0ace
--- /dev/null
+++ b/t/00-basic.rakutest
@@ -0,0 +1,12 @@
+use Test;
+
+plan 8;
+
+use-ok 'Octans::CLI';
+use-ok 'Octans::Hex2RGB';
+use-ok 'Octans::Neighbors';
+use-ok 'Octans::WordSearch';
+use-ok 'Octans::Puzzle';
+use-ok 'Octans::Puzzle:Get';
+use-ok 'Octans::RangeSearch';
+use-ok 'Octans::GenerateFrame';
diff --git a/t/01-hex2rgb.rakutest b/t/01-hex2rgb.rakutest
new file mode 100644
index 0000000..313b957
--- /dev/null
+++ b/t/01-hex2rgb.rakutest
@@ -0,0 +1,8 @@
+use Test;
+use Octans::Hex2RGB;
+
+plan 3;
+
+is hex2rgb("#000000"), (0, 0, 0), "#000000";
+is hex2rgb("#b753db"), (183, 83, 219).map(* / 255), "#b753db";
+is hex2rgb("#ffffff"), (1, 1, 1), "#ffffff";