Initial commit

master
entuland 2018-07-04 17:29:53 +02:00
commit 8dd6313ba5
31 changed files with 2757 additions and 0 deletions

2
.gitattributes vendored Normal file
View File

@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018 entuland
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

130
README.md Normal file
View File

@ -0,0 +1,130 @@
# imaging
An image / bumpmap importer mod for Minetest
Developed and tested on Minetest 0.4.16 - try in other versions at your own risk :)
WIP mod forum discussion: ...
**Table of Contents**
- [Dependencies and Licensing](#dependencies-and-licensing)
- [Features](#features)
- [Recipe](#recipe)
- [Converter](#converter)
- [Converter options](#converter-options)
- [Importer](#importer)
- [Importer options](#importer-options)
- [Bump mapping](#bump-mapping)
- [Imaging data format](#imaging-data-format)
- [Custom palettes](#custom-palettes)
## Dependencies and Licensing
This mod depends on the [[matrix]](https://github.com/entuland/lua-matrix) mod.
The code is licensed under the [MIT](/LICENSE) license.
Except otherwise specified, all media is licensed as [CC BY SA 3.0](http://creativecommons.org/licenses/by-sa/3.0/)
[The Minetest Logo screenshot](/screenshots/minetest-logo) is [CC BY SA 3.0 Minetest team](https://github.com/minetest/minetest/blob/master/LICENSE.txt)
[The sardinian girl screenshot](/screenshots/sardinian-girl.png) is [CC BY 2.0 Cristiano Cani](https://www.flickr.com/photos/cristianocani/2457125478/)
## Features
This mod allows to convert arbitrary images and build them in a Minetest world using up to 256 different colors, with some additional options such as bumpmapping.
This mod is composed by two parts:
- `the converter` is an HTML page to be run in a browser to convert images into `imaging` codes according to a given palette
- `the importer` is a block called `imaging:canvas` placed in the world to define the bottom-center of the imported image and its orientation
## Recipe
The recipe can be customized altering the file `custom.recipes.lua`, created in the mod's folder on first run and never overwritten.
W = any wood planks
B = mese block
WWW
W W
WBW
![Crafting](/screenshots/canvas-recipe.png)
## Converter
To launch the converter run the `/html/index.html` file contained in the mod folder, you should see something like this:
![Converter](/screenshots/converter.png)
There you can load an image by clicking on the file selector marked in yellow in the *Image Input* box (you can also drag files on that file selector), then alter the conversion params and hit the `Generate imaging data` button, obtaining something like this:
![Converter Output](/screenshots/converter-output.png)
Clicking in the yellow *Output* textarea all the text will be selected: copy it into the clipboard and keep it handy to be imported in the game.
### Converter options
- The three `Palette` buttons load one of the three default palettes; you can use any custom palette as long as it has exactly 256 pixels; in order to use such a palette you'll also need to add it to the mod `/textures` folder and restart the world
- The `Dithering` checkbox adds [Floyd-Steinberg dithering](https://en.wikipedia.org/wiki/Floyd%E2%80%93Steinberg_dithering) to the resulting image.
- The `Minimum opacity` number determines what pixels will be considered and which ones will be ignored (the default `255` value will ignore any pixel that is not fully opaque, lower this value to use partially-transparent pixels)
## Importer
This is how the `imaging:canvas` importer looks like:
![Imaging Canvas](/screenshots/imaging-canvas.png)
You can freely rotate it to decide in what direction the image will be imported.
Once you right click it you'll see the default import interface:
![Default Interface](/screenshots/default-interface.png)
There you need to paste the `imaging data` you copied from the `converter` and click on `Build`, obtaining something like this:
![Minetest Logo](/screenshots/minetest-logo.png)
You can import pretty large images as well, this is the result of converting a portion of this image using the `sepia` palette:
https://www.flickr.com/photos/cristianocani/2457125478/
![Sardinian girl](/screenshots/sardinian-girl.png)
### Importer options
- The `imaging data` only contains the info about the palette index for each pixel, for that reason it is necessary to choose the appropriate palette in the dropdown of the importer as well (or not, you can also use a different palette which may result in some weird "fake colors" effect).
- `Build as`: if you select this option the image will be built using the chosen nodes - you can also build as `air` to get rid of an image you previously built.
- `Bump value`: if you set any positive value in this field, the palette index will be used to "bump" the nodes out of the image; for example, if you set the bump value to `10` then pixels with index `255` will be 10 nodes away from their original position in the plane of the image, indices around `127` will be about 5 nodes away and so forth.
(note: in a palette such as the `grayscale` one, black is at index `0` and white is at index `255`, with gradients of gray getting brighter as the index increases)
### Bump mapping
For example, you can draw an elevation map with an airbrush and convert it like this:
![Converter bump](/screenshots/converter-bump.png)
Then you can import it with these params:
![Bump build](/screenshots/bump-build.png)
And finally obtain something like this:
![Bump result](/screenshots/bump-result.png)
## Imaging Data Format
The `imaging data` is a run-length textual format defining the image in top-down, left-right order with these specifics:
- the data is a space separated series of `chunks`
- the first chunk is the width of the image
- the second chunk is the height of the image
- any subsequent chunk follows this format `index:count` where
- `index` can be missing (those nodes will be completely ignored), for example `:200` means "skip 200 nodes"
- `count` can be missing (meaning that the count will be `1`), for instance `0:` using the grayscale palette means "one black node" and `255:3` means "three white nodes"
All the above means that you can use the `imaging:canvas` to get rid of any image even without having the original code - it's enough that you guess the sizes and build them as `air` - for instance to remove a 32 x 48 image you would paste a code like this `32 48 0:1536` cause `32 * 48 == 1536`; notice that you _need_ to specify it as `0:1536`, cause `:1536` alone means "skip 1536 nodes" and those nodes will not be altered in the world regardless if you chose to build as `air` or `default:dirt` or anything else.
## Custom palettes
Custom palettes are PNG images with exactly 256 pixels (say, 16x16 or 32x8) that must be placed into the `/textures` folder and named exactly as `palette-customname.png` (where "customname" can be anything you want).
Upon world restart the mod will find that custom palette and will add it to the palettes' dropdown in the `imaging:canvas` interface.
Those custom palettes will *not* be picked up automatically by the HTML `converter`, though: you'll need to drag/open such palettes manually in the browser's interface.

2
default/README.txt Normal file
View File

@ -0,0 +1,2 @@
please do not edit any file in this folder,
corresponding custom.* files get created in the main mod's folder for you to customize

12
default/recipes.lua Normal file
View File

@ -0,0 +1,12 @@
-- only alter this file if it's named "custom.recipes.lua"
-- alter the recipes as you please and delete / comment out
-- the recipes you don't want to be available in the game
-- the original versions are in "default/recipes.lua"
return {
["imaging:canvas"] = {
{'group:wood', 'group:wood', 'group:wood'},
{'group:wood', '', 'group:wood'},
{'group:wood', 'default:mese', 'group:wood'},
},
}

1
depends.txt Normal file
View File

@ -0,0 +1 @@
matrix

65
html/css/index.css Normal file
View File

@ -0,0 +1,65 @@
body {
font-family: sans-serif;
}
textarea, [type="file"] {
background: yellow;
width: 100%;
margin: 0 auto;
display: block;
margin-bottom: 1em;
}
textarea {
height: 5em;
}
canvas, img {
image-rendering: pixelated;
width: 100%;
height: auto;
background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAYAAABWdVznAAAAH0lEQVQokWNYtGjRf2w4ISEBK2YYkRpwSeAyaERqAABq/mkwVExPHAAAAABJRU5ErkJggg==') top left repeat;
}
#main {
display: grid;
grid-template-columns: 1fr 1fr;
}
#main > div {
border: 1px solid #ccc;
background: #eee;
padding: 1em;
margin: 1ex;
}
.error {
color: white;
font-weight: bolder;
background: #A00;
padding: 1ex;
}
#cover {
display: none;
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
}
#progress {
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
font-size: 200%;
color: white;
}
#controls label, #controls button {
display: block;
margin: 1ex;
}

46
html/index.html Normal file
View File

@ -0,0 +1,46 @@
<!doctype html>
<html>
<head>
<title>Minetest Imaging Converter</title>
<meta charset="utf-8" />
<link href="css/index.css" rel="stylesheet">
<script src="js/index.js"></script>
<script src="js/images.js"></script>
</head>
<body>
<h1>Minetest Imaging Converter</h1>
<div id="main">
<div>
<h2>Image input</h2>
<img id="imagePreview">
<p>Drag or select the image you want to convert</p>
<input id="imageInput" type="file">
</div>
<div>
<h2>Result preview</h2>
<canvas id="resultPreview"></canvas>
</div>
<div>
<h2>Palette input</h2>
<img id="palettePreview">
<button id="loadVGA">VGA Palette</button>
<button id="loadGrayscale">Grayscale Palette</button>
<button id="loadSepia">Sepia Palette</button>
<p>Drag or select the palette you want to use</p>
<input id="paletteInput" type="file">
</div>
<div>
<h2>Output</h2>
<textarea id="output"></textarea>
<div id="controls">
<label>Dithering <input type="checkbox" id="dithering"></label>
<label>Minimum opacity: <input type="number" min="0" max="255" id="minAlpha" step="1" value="255"></label>
<button id="execute">Generate imaging data</button>
</div>
</div>
</div>
<div id="cover">
<div id="progress"></div>
</div>
</body>
</html>

7
html/js/images.js Normal file
View File

@ -0,0 +1,7 @@
app.images = {
palettes: {
vga: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAICAMAAACvWw2dAAADAFBMVEUAAAAAAFUAAKoAAP8AJAAAJFUAJKoAJP8ASQAASVUASaoASf8AbQAAbVUAbaoAbf8AkgAAklUAkqoAkv8AtgAAtlUAtqoAtv8A2wAA21UA26oA2/8A/wAA/1UA/6oA//8kAAAkAFUkAKokAP8kJAAkJFUkJKokJP8kSQAkSVUkSaokSf8kbQAkbVUkbaokbf8kkgAkklUkkqokkv8ktgAktlUktqoktv8k2wAk21Uk26ok2/8k/wAk/1Uk/6ok//9JAABJAFVJAKpJAP9JJABJJFVJJKpJJP9JSQBJSVVJSapJSf9JbQBJbVVJbapJbf9JkgBJklVJkqpJkv9JtgBJtlVJtqpJtv9J2wBJ21VJ26pJ2/9J/wBJ/1VJ/6pJ//9tAABtAFVtAKptAP9tJABtJFVtJKptJP9tSQBtSVVtSaptSf9tbQBtbVVtbaptbf9tkgBtklVtkqptkv9ttgBttlVttqpttv9t2wBt21Vt26pt2/9t/wBt/1Vt/6pt//+SAACSAFWSAKqSAP+SJACSJFWSJKqSJP+SSQCSSVWSSaqSSf+SbQCSbVWSbaqSbf+SkgCSklWSkqqSkv+StgCStlWStqqStv+S2wCS21WS26qS2/+S/wCS/1WS/6qS//+2AAC2AFW2AKq2AP+2JAC2JFW2JKq2JP+2SQC2SVW2Saq2Sf+2bQC2bVW2baq2bf+2kgC2klW2kqq2kv+2tgC2tlW2tqq2tv+22wC221W226q22/+2/wC2/1W2/6q2///bAADbAFXbAKrbAP/bJADbJFXbJKrbJP/bSQDbSVXbSarbSf/bbQDbbVXbbarbbf/bkgDbklXbkqrbkv/btgDbtlXbtqrbtv/b2wDb21Xb26rb2//b/wDb/1Xb/6rb////AAD/AFX/AKr/AP//JAD/JFX/JKr/JP//SQD/SVX/Sar/Sf//bQD/bVX/bar/bf//kgD/klX/kqr/kv//tgD/tlX/tqr/tv//2wD/21X/26r/2////wD//1X//6r////qm24uAAAAAWJLR0QAiAUdSAAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB+IHAhUSH2BgGA8AAAETSURBVBjTAQgB9/4AAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8AICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8AQEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl8AYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn8AgIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp8AoKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr8AwMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t8A4OHi4+Tl5ufo6err7O3u7/Dx8vP09fb3+Pn6+/z9/v/ETX+Bhyaj2QAAAABJRU5ErkJggg==",
sepia: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAICAMAAACvWw2dAAADAFBMVEUIBAAJBQEKBgILBwIMCAMNCQQOCgUPCgUQCwYRDAcSDQgTDggUDwkVEAoWEQsXEgsXEwwYFA0ZFQ4aFg4bFw8cFxAdGBEeGREfGhIgGxMhHBQiHRQjHhUkHxYlIBcmIRcnIhgoIxkpIxoqJBorJRssJhwtJx0uKB0vKR4wKh8xKyAyLCAzLSE0LiI1LyM2LyM2MCQ3MSU4MiY5MyY6NCc7NSg8Nik9Nyk+OCo/OStAOixBOyxCPC1DPC5EPS9FPi9GPzBHQDFIQTJJQjJKQzNLRDRMRTVNRjVORzZPSDdQSDhRSThSSjlTSzpUTDtVTTtVTjxWTz1XUD5YUT5ZUj9aU0BbVEFcVUJdVUJeVkNfV0RgWEVhWUViWkZjW0dkXEhlXUhmXklnX0poYEtpYUtqYUxrYk1sY05tZE5uZU9vZlBwZ1FxaFFyaVJzalN0a1R0bFR1bVV2blZ3bld4b1d5cFh6cVl7clp8c1p9dFt+dVx/dl2Ad12BeF6CeV+DemCEemCFe2GGfGKHfWOIfmOJf2SKgGWLgWaMgmaNg2eOhGiPhWmQhmmRhmqSh2uTiGyTiWyUim2Vi26WjG+XjW+YjnCZj3GakHKbkXKcknOdk3Sek3WflHWglXahlneil3ijmHikmXmlmnqmm3unnHuonXypnn2qn36rn36soH+toYCuooGvo4KwpIKxpYOypoSyp4WzqIW0qYa1qoe2q4i3rIi4rIm5rYq6rou7r4u8sIy9sY2+so6/s47AtI/BtZDCtpHDt5HEuJLFuJPGuZTHupTIu5XJvJbKvZfLvpfMv5jNwJnOwZrPwprQw5vRxJzRxZ3SxZ3Txp7Ux5/VyKDWyaDXyqHYy6LZzKPazaPbzqTcz6Xd0Kbe0abf0afg0qjh06ni1Knj1ark1qvl16zm2Kzn2a3o2q7p26/q3K/r3bDs3bHt3rLu37Lv4LPw4bTw4rXx47Xy5Lbz5bf05rj157j26Ln36br46rv56rv667z77L387b797r7+77////9k4bs9AAAAAWJLR0QAiAUdSAAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB+IHAhUOM7TPKbEAAAETSURBVBjTAQgB9/4AAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8AICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8AQEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl8AYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn8AgIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp8AoKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr8AwMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t8A4OHi4+Tl5ufo6err7O3u7/Dx8vP09fb3+Pn6+/z9/v7ETH+AzIPQyAAAAABJRU5ErkJggg==",
grayscale: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAICAMAAACvWw2dAAADAFBMVEUAAAABAQECAgIDAwMEBAQFBQUGBgYHBwcICAgJCQkKCgoLCwsMDAwNDQ0ODg4PDw8QEBARERESEhITExMUFBQVFRUWFhYXFxcYGBgZGRkaGhobGxscHBwdHR0eHh4fHx8gICAhISEiIiIjIyMkJCQlJSUmJiYnJycoKCgpKSkqKiorKyssLCwtLS0uLi4vLy8wMDAxMTEyMjIzMzM0NDQ1NTU2NjY3Nzc4ODg5OTk6Ojo7Ozs8PDw9PT0+Pj4/Pz9AQEBBQUFCQkJDQ0NERERFRUVGRkZHR0dISEhJSUlKSkpLS0tMTExNTU1OTk5PT09QUFBRUVFSUlJTU1NUVFRVVVVWVlZXV1dYWFhZWVlaWlpbW1tcXFxdXV1eXl5fX19gYGBhYWFiYmJjY2NkZGRlZWVmZmZnZ2doaGhpaWlqampra2tsbGxtbW1ubm5vb29wcHBxcXFycnJzc3N0dHR1dXV2dnZ3d3d4eHh5eXl6enp7e3t8fHx9fX1+fn5/f3+AgICBgYGCgoKDg4OEhISFhYWGhoaHh4eIiIiJiYmKioqLi4uMjIyNjY2Ojo6Pj4+QkJCRkZGSkpKTk5OUlJSVlZWWlpaXl5eYmJiZmZmampqbm5ucnJydnZ2enp6fn5+goKChoaGioqKjo6OkpKSlpaWmpqanp6eoqKipqamqqqqrq6usrKytra2urq6vr6+wsLCxsbGysrKzs7O0tLS1tbW2tra3t7e4uLi5ubm6urq7u7u8vLy9vb2+vr6/v7/AwMDBwcHCwsLDw8PExMTFxcXGxsbHx8fIyMjJycnKysrLy8vMzMzNzc3Ozs7Pz8/Q0NDR0dHS0tLT09PU1NTV1dXW1tbX19fY2NjZ2dna2trb29vc3Nzd3d3e3t7f39/g4ODh4eHi4uLj4+Pk5OTl5eXm5ubn5+fo6Ojp6enq6urr6+vs7Ozt7e3u7u7v7+/w8PDx8fHy8vLz8/P09PT19fX29vb39/f4+Pj5+fn6+vr7+/v8/Pz9/f3+/v7////isF19AAAAAWJLR0QAiAUdSAAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB+IHAhUMM4b5SzMAAAETSURBVBjTAQgB9/4AAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8AICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8AQEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl8AYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn8AgIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp8AoKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr8AwMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t8A4OHi4+Tl5ufo6err7O3u7/Dx8vP09fb3+Pn6+/z9/v/ETX+Bhyaj2QAAAABJRU5ErkJggg==",
},
};

437
html/js/index.js Normal file
View File

@ -0,0 +1,437 @@
'use strict';
function Palette() {
var self = this;
self.colors = [];
var cache = {};
self.closest = function(rgb) {
var key = rgb.join(",");
if(!cache[key]) {
var min_distance = 3 * (255 * 255);
var closest = -1;
for(var i = 0; i < self.colors.length; ++i) {
var dist = squaredDist(rgb, self.colors[i]);
if(dist < min_distance) {
min_distance = dist;
closest = i;
}
}
cache[key] = closest;
}
return cache[key];
}
}
function squaredDist(a, b) {
return Math.pow(a[0] - b[0], 2) +Math.pow(a[1] - b[1], 2) + Math.pow(a[2] - b[2], 2);
}
function addRGB(a, b) {
return [
a[0] + b[0],
a[1] + b[1],
a[2] + b[2],
a[3],
];
}
function subRGB(a, b) {
return [
a[0] - b[0],
a[1] - b[1],
a[2] - b[2],
a[3],
];
}
function mulRGB(a, b) {
return [
a[0] * b[0],
a[1] * b[1],
a[2] * b[2],
a[3],
];
}
function divRGB(a, b) {
return [
a[0] / b[0],
a[1] / b[1],
a[2] / b[2],
a[3],
];
}
function addN(a, n) {
return [
a[0] + n,
a[1] + n,
a[2] + n,
a[3],
];
}
function subN(a, n) {
return [
a[0] - n,
a[1] - n,
a[2] - n,
a[3],
];
}
function mulN(a, n) {
return [
a[0] * n,
a[1] * n,
a[2] * n,
a[3],
];
}
function divN(a, n) {
return [
a[0] / n,
a[1] / n,
a[2] / n,
a[3],
];
}
function clamp(min, n, max) {
if(n < min) { return min; }
if(n > max) { return max; }
return n;
}
function clampRGB(a) {
return [
clamp(0, a[0], 255),
clamp(0, a[1], 255),
clamp(0, a[2], 255),
clamp(0, a[3], 255),
];
}
function MatrixHelper(canvas) {
var ctx = canvas.getContext("2d");
var raw = ctx.getImageData(0, 0, canvas.width, canvas.height);
var self = this;
self.width = canvas.width;
self.height = canvas.height;
self.indices = [];
self.setIndex = function(x, y, index) {
self.indices[y * self.width + x] = index;
};
self.getPixel = function(x, y) {
var index = (y * self.width + x) * 4;
return [
raw.data[index],
raw.data[index+1],
raw.data[index+2],
raw.data[index+3],
];
};
self.setPixel = function(x, y, pixel) {
var index = (y * self.width + x) * 4;
raw.data[index] = pixel[0];
raw.data[index+1] = pixel[1];
raw.data[index+2] = pixel[2];
raw.data[index+3] = pixel[3];
};
self.update = function() {
ctx.putImageData(raw, 0, 0);
};
}
var app = {
init: function() {
app.imageInput = document.querySelector("#imageInput");
app.imagePreview = document.querySelector("#imagePreview");
app.paletteInput = document.querySelector("#paletteInput");
app.palettePreview = document.querySelector("#palettePreview");
app.dithering = document.querySelector("#dithering")
app.minAlpha = document.querySelector("#minAlpha")
app.coverDiv = document.querySelector("#cover");
app.progressDiv = document.querySelector("#progress");
app.resultPreview = document.querySelector("#resultPreview");
app.output = document.querySelector("#output");
app.output.addEventListener("focus", function() {
app.output.select();
});
app.initImageLoader();
app.initPaletteLoader();
app.initPaletteButtons();
app.executeButton = document.querySelector("#execute");
app.executeButton.addEventListener("click", app.generateOutput);
},
progress: function(msg) {
app.progressDiv.innerHTML = msg;
},
progressXY: function(x, y, matrix) {
var total = matrix.width * matrix.height;
var current = y * matrix.width + x;
var percent = Math.floor(100 / total * current);
app.progress("Progress: " + percent + "%");
},
finalize: function(matrix) {
matrix.update();
app.resultPreview.style.display = "block";
app.coverDiv.style.display = "none";
var cells = [];
cells.push(matrix.width);
cells.push(matrix.height);
var curcell = {
index: false,
count: 0.
};
function addCell(cell) {
if(cell.count) {
var index = cell.index === false ? "" : cell.index;
var count = cell.count > 1 ? cell.count : "";
cells.push(index + ":" + count);
}
}
for(var i = 0; i < matrix.indices.length; ++i) {
var index = matrix.indices[i];
if(index === curcell.index) {
curcell.count++;
} else {
addCell(curcell);
curcell = {
index: index,
count: 1,
};
}
}
addCell(curcell);
app.output.value = cells.join(" ");
},
generateOutput: function() {
if(!app.validPalette()) {
return;
}
if(!app.validImage()) {
return;
}
app.coverDiv.style.display = "block";
var palette = app.extractPalette();
var canvas = app.resultPreview;
canvas.style.display = "none";
var ctx = canvas.getContext("2d");
canvas.height = app.imagePreview.naturalHeight;
canvas.width = app.imagePreview.naturalWidth;
ctx.drawImage(app.imagePreview, 0, 0);
var matrix = new MatrixHelper(canvas);
function process_next_pixel(x, y, callback, batch) {
app.progressXY(x, y, matrix);
var iterations = batch;
while(iterations--) {
if(x >= matrix.width) {
x = 0;
++y;
}
if(y >= matrix.height) {
setTimeout(function() {
app.finalize(matrix);
}, 0);
return;
}
callback(x, y, matrix);
++x;
}
setTimeout(function() {
process_next_pixel(x, y, callback, batch);
}, 0);
}
function plainColorCallback(x, y) {
var px = matrix.getPixel(x, y)
if(px[3] < app.minAlphaValue) {
px[0] = px[1] = px[2] = px[3] = 0;
matrix.setPixel(x, y, px);
matrix.setIndex(x, y, false);
return;
} else {
px[3] = 255;
}
var index = palette.closest(px);
if(index > -1) {
px = palette.colors[index];
matrix.setPixel(x, y, px);
matrix.setIndex(x, y, index);
} else {
matrix.setIndex(x, y, false);
}
}
function maybeAdd(x, y, add) {
if(x < 0 || x >= matrix.width || y >= matrix.height) {
return;
}
var px = matrix.getPixel(x, y);
px = clampRGB(addRGB(px, add));
matrix.setPixel(x, y, px);
}
function ditheredColorCallback(x, y, matrix) {
var px = matrix.getPixel(x, y);
if(px[3] < app.minAlphaValue) {
px[0] = px[1] = px[2] = px[3] = 0;
matrix.setPixel(x, y, px);
matrix.setIndex(x, y, false);
return;
} else {
px[3] = 255;
}
var index = palette.closest(px);
if(index > -1) {
var new_px = palette.colors[index];
matrix.setPixel(x, y, new_px);
matrix.setIndex(x, y, index);
var error = subRGB(px, new_px)
maybeAdd(x + 1, y , mulN(error, 7/16));
maybeAdd(x - 1, y + 1, mulN(error, 3/16));
maybeAdd(x , y + 1, mulN(error, 5/16));
maybeAdd(x + 1, y + 1, mulN(error, 1/16));
} else {
matrix.setIndex(x, y, false);
}
}
app.minAlphaValue = parseInt(app.minAlpha.value);
var callback = plainColorCallback;
if(app.dithering.checked) {
callback = ditheredColorCallback;
}
var batch = Math.floor(canvas.height * canvas.width * 0.01)
if(batch < 1) {
batch = 1;
}
process_next_pixel(0, 0, callback, batch);
},
extractPalette: function() {
var canvas = document.createElement("canvas");
var ctx = canvas.getContext("2d");
canvas.height = app.palettePreview.naturalHeight;
canvas.width = app.palettePreview.naturalWidth;
ctx.drawImage(app.palettePreview, 0, 0);
var palette = new Palette();
for(var y = 0; y < canvas.height; ++y) {
for(var x = 0; x < canvas.width; ++x) {
var px = ctx.getImageData(x, y, 1, 1);
palette.colors.push([
px.data[0],
px.data[1],
px.data[2],
px.data[3],
])
}
}
return palette;
},
error: function(after, message) {
var errors = after.parentNode.querySelectorAll(".error");
for(var i = 0; i < errors.length; ++i) {
errors[i].remove();
}
if(message) {
var div = document.createElement("div");
div.classList.add("error");
div.innerText = message;
after.insertAdjacentElement("afterend", div);
}
},
validImage: function() {
if(!app.imagePreview.naturalHeight) {
app.error(app.imagePreview, "Invalid or missing image")
return false;
}
app.error(app.imagePreview)
return true;
},
validPalette: function() {
if(app.palettePreview.naturalHeight * app.palettePreview.naturalWidth !== 256) {
app.error(app.palettePreview, "Invalid palette size (pixel count must be 256)")
return false;
}
app.error(app.palettePreview)
return true;
},
initImageLoader: function() {
var imageReader = new FileReader();
imageReader.addEventListener('load', function () {
app.imagePreview.src = imageReader.result;
}, false);
app.imageInput.addEventListener('change', function() {
var file = app.imageInput.files[0];
if(file) {
imageReader.readAsDataURL(file);
}
});
app.imagePreview.addEventListener("load", function() {
app.validImage();
});
},
initPaletteLoader: function() {
var paletteReader = new FileReader();
paletteReader.addEventListener('load', function () {
app.palettePreview.src = paletteReader.result;
}, false);
app.paletteInput.addEventListener('change', function() {
var file = app.paletteInput.files[0];
if(file) {
paletteReader.readAsDataURL(file);
}
});
app.palettePreview.addEventListener("load", function() {
app.validPalette();
});
},
initPaletteButtons: function() {
document.querySelector("#loadVGA").addEventListener("click", function() {
app.palettePreview.src = app.images.palettes.vga;
});
document.querySelector("#loadGrayscale").addEventListener("click", function() {
app.palettePreview.src = app.images.palettes.grayscale;
});
document.querySelector("#loadSepia").addEventListener("click", function() {
app.palettePreview.src = app.images.palettes.sepia;
});
app.palettePreview.src = app.images.palettes.vga;
},
}
window.addEventListener('load', app.init);

514
init.lua Normal file
View File

@ -0,0 +1,514 @@
imaging = {}
local mod_path = minetest.get_modpath(minetest.get_current_modname());
local smartfs = dofile(mod_path .. "/lib/smartfs.lua")
local notify = dofile(mod_path .. "/notify.lua")
-- constants
local POS = {}
local NEG = {}
POS.Y = 0
POS.Z = 1
NEG.Z = 2
POS.X = 3
NEG.X = 4
NEG.Y = 5
-- ============================================================
-- helper variables
local rot_matrices = {}
local dir_matrices = {}
local facedir_memory = {}
-- ============================================================
-- init
local function init_transforms()
local rot = {}
local dir = {}
-- no rotation
rot[0] = matrix{{ 1, 0, 0},
{ 0, 1, 0},
{ 0, 0, 1}}
-- 90 degrees clockwise
rot[1] = matrix{{ 0, 0, 1},
{ 0, 1, 0},
{ -1, 0, 0}}
-- 180 degrees
rot[2] = matrix{{ -1, 0, 0},
{ 0, 1, 0},
{ 0, 0, -1}}
-- 270 degrees clockwise
rot[3] = matrix{{ 0, 0, -1},
{ 0, 1, 0},
{ 1, 0, 0}}
rot_matrices = rot
-- directions
-- Y+
dir[0] = matrix{{ 1, 0, 0},
{ 0, 1, 0},
{ 0, 0, 1}}
-- Z+
dir[1] = matrix{{ 1, 0, 0},
{ 0, 0, -1},
{ 0, 1, 0}}
-- Z-
dir[2] = matrix{{ 1, 0, 0},
{ 0, 0, 1},
{ 0, -1, 0}}
-- X+
dir[3] = matrix{{ 0, 1, 0},
{ -1, 0, 0},
{ 0, 0, 1}}
-- X-
dir[4] = matrix{{ 0, -1, 0},
{ 1, 0, 0},
{ 0, 0, 1}}
-- Y-
dir[5] = matrix{{ -1, 0, 0},
{ 0, -1, 0},
{ 0, 0, 1}}
dir_matrices = dir
imaging._facedir_transform = {}
imaging._matrix_to_facedir = {}
for facedir = 0, 23 do
local direction = math.floor(facedir / 4)
local rotation = facedir % 4
local transform = dir[direction] * rot[rotation]
imaging._facedir_transform[facedir] = transform
imaging._matrix_to_facedir[transform:tostring():gsub("%-0", "0")] = facedir
end
end
init_transforms()
-- ============================================================
-- helper functions
local function cross_product(a, b)
return vector.new(
a.y * b.z - a.z * b.y,
a.z * b.x - a.x * b.z,
a.x * b.y - a.y * b.x
)
end
local function extract_main_axis(dir)
local axes = { "x", "y", "z" }
local axis = 1
local max = 0
for i = 1, 3 do
local abs = math.abs(dir[axes[i]])
if abs > max then
axis = i
max = abs
end
end
return axes[axis]
end
local function sign(num)
return (num < 0) and -1 or 1
end
local function extract_unit_vectors(player, pointed_thing)
assert(pointed_thing.type == "node")
local abs_face_pos = minetest.pointed_thing_to_face_pos(player, pointed_thing)
local pos = pointed_thing.under
local f = vector.subtract(abs_face_pos, pos)
local facedir = 0
local primary = 0
local m1, m2
local unit_direction = vector.new()
local unit_rotation = vector.new()
local rotation = vector.new()
if math.abs(f.y) == 0.5 then
unit_direction.y = sign(f.y)
rotation.x = f.x
rotation.z = f.z
elseif math.abs(f.z) == 0.5 then
unit_direction.z = sign(f.z)
rotation.x = f.x
rotation.y = f.y
else
unit_direction.x = sign(f.x)
rotation.y = f.y
rotation.z = f.z
end
local main_axis = extract_main_axis(rotation)
unit_rotation[main_axis] = sign(rotation[main_axis])
return {
back = unit_direction,
wrap = unit_rotation,
thumb = cross_product(unit_direction, unit_rotation),
}
end
local function apply_transform(pos, transform)
return {
x = pos.x * transform[1][1] + pos.y * transform[1][2] + pos.z * transform[1][3],
y = pos.x * transform[2][1] + pos.y * transform[2][2] + pos.z * transform[2][3],
z = pos.x * transform[3][1] + pos.y * transform[3][2] + pos.z * transform[3][3],
}
end
local function get_facedir_transform(facedir)
return imaging._facedir_transform[facedir] or imaging._facedir_transform[0]
end
local function matrix_to_facedir(mtx)
local key = mtx:tostring():gsub("%-0", "0")
if not imaging._matrix_to_facedir[key] then
error("Unsupported matrix:\n" .. key)
end
return imaging._matrix_to_facedir[key]
end
local function vector_to_dir_index(vec)
local main_axis = extract_main_axis(vec)
if main_axis == "x" then return (vec.x > 0) and POS.X or NEG.X end
if main_axis == "z" then return (vec.z > 0) and POS.Z or NEG.Z end
return (vec.y > 0) and POS.Y or NEG.Y
end
-- ========================================================================
-- local helpers
local function copy_file(source, dest)
local src_file = io.open(source, "rb")
if not src_file then
return false, "copy_file() unable to open source for reading"
end
local src_data = src_file:read("*all")
src_file:close()
local dest_file = io.open(dest, "wb")
if not dest_file then
return false, "copy_file() unable to open dest for writing"
end
dest_file:write(src_data)
dest_file:close()
return true, "files copied successfully"
end
local function custom_or_default(modname, path, filename)
local default_filename = "default/" .. filename
local full_filename = path .. "/custom." .. filename
local full_default_filename = path .. "/" .. default_filename
os.rename(path .. "/" .. filename, full_filename)
local file = io.open(full_filename, "rb")
if not file then
minetest.debug("[" .. modname .. "] Copying " .. default_filename .. " to " .. filename .. " (path: " .. path .. ")")
local success, err = copy_file(full_default_filename, full_filename)
if not success then
minetest.debug("[" .. modname .. "] " .. err)
return false
end
file = io.open(full_filename, "rb")
if not file then
minetest.debug("[" .. modname .. "] Unable to load " .. filename .. " file from path " .. path)
return false
end
end
file:close()
return full_filename
end
-- ============================================================
-- palette functions
local function textToGrid(text)
local parts = text:split(" ")
if #parts < 3 then
return false, "Invalid paste data"
end
local width = tonumber(parts[1])
local height = tonumber(parts[2])
if width < 1 or height < 1 then
return false, "Invalid sizes: " .. parts[1] .. " " .. parts[2]
end
local index = 2
local rows = {}
local x = 0
local y = 0
local function addPixel(paletteIndex)
if x >= width then
x = 0
y = y + 1
end
if y >= height then
return false, "Pixels exceed declared sizes"
end
if not rows[y] then
rows[y] = {}
end
rows[y][x] = paletteIndex
x = x + 1
return true
end
local function processCell(cell)
local includeEmpty = true
local parts = cell:split(":", includeEmpty)
if #parts ~= 2 then
return false, "Invalid cell format: " .. cell
end
local paletteIndex = parts[1] ~= "" and tonumber(parts[1]) or false
local count = parts[2] == "" and 1 or tonumber(parts[2])
if paletteIndex ~= false and (paletteIndex < 0 or paletteIndex > 255) then
return false, "Invalid cell index: " .. cell
end
if count == 0 then
return false, "Invalid cell count: " .. cell
end
for c = 1, count do
local success, err = addPixel(paletteIndex)
if not success then
return false, err
end
end
return true
end
for index, cell in ipairs(parts) do
if index > 2 then
local success, err = processCell(cell)
if not success then
return false, err
end
end
end
return {
width = width,
height = height,
rows = rows,
}
end
local function getPaletteNames()
local entries = minetest.get_dir_list(mod_path .. "/textures")
local names = {}
for _, entry in ipairs(entries) do
local name = entry:match("^palette%-(.+)%.png$")
if name then
names[name] = entry
end
end
return names
end
imaging.init = function()
imaging.palettes = getPaletteNames()
for name, palette in pairs(imaging.palettes) do
local def = {
description = "Imaging " .. name,
paramtype = "light",
paramtype2 = "color",
tiles = { "white.png" },
palette = palette,
groups = {cracky = 3, not_in_creative_inventory = 1},
}
minetest.register_node("imaging:palette_" .. name, def)
end
local node_box = {
type = "fixed",
fixed = {
{-0.5, -0.5, -0.15, 0.5, 0.5, 0.15},
},
}
minetest.register_node("imaging:canvas", {
drawtype = "nodebox",
description = "Imaging Canvas",
tiles = {
"black.png",
"black.png",
"black.png",
"black.png",
"back.png",
"front.png"
},
paramtype = "light",
paramtype2 = "facedir",
node_box = node_box,
groups = {cracky = 3 },
on_rightclick = imaging.on_rightclick,
})
local full_recipes_filename = custom_or_default("imaging", mod_path, "recipes.lua")
if not full_recipes_filename then return end
local recipes = dofile(full_recipes_filename);
if recipes["imaging:canvas"] then
minetest.register_craft({
output = "imaging:canvas",
recipe = recipes["imaging:canvas"]
})
end
end
local clicked_node = {}
local main_memory = {}
imaging.on_rightclick = function(clicked_pos, node, clicker)
local playername = clicker:get_player_name()
node.pos = clicked_pos
clicked_node[playername] = node
local state = imaging.forms.main:show(playername)
local memory = main_memory[playername]
if not memory then return end
if memory.text then state:get("paste"):setText(memory.text) end
if memory.palette then state:get("palettes"):setSelectedItem(memory.palette) end
state:get("replacer"):setValue(memory.replacer)
if memory.replacement then state:get("replacement"):setText(memory.replacement) end
if memory.bumpvalue then state:get("bumpvalue"):setText(memory.bumpvalue) end
end
imaging.generate = function(_, state)
local text = state:get("paste"):getText()
local palette = state:get("palettes"):getSelectedItem()
local replacer = state:get("replacer"):getValue()
local replacement = state:get("replacement"):getText()
local bumpvalue = tonumber(state:get("bumpvalue"):getText())
if replacer then
local def = minetest.registered_nodes[replacement]
if not def then
notify.err(state.player, "Invalid node entered: " .. replacement)
return
end
else
replacement = false
end
if type(bumpvalue) ~= "number" or bumpvalue < 0 then
bumpvalue = 0
end
if not imaging.palettes[palette] then
notify.err(state.player, "Invalid palette name " .. palette)
return
end
local grid, err = textToGrid(text)
if not grid then
notify.err(state.player, err)
return
end
main_memory[state.player] = {
palette = palette,
replacer = replacer,
replacement = replacement,
bumpvalue = bumpvalue,
text = text,
}
imaging.fillGrid(state.player, palette, grid, replacement, bumpvalue)
end
imaging.fillGrid = function(playername, palette, grid, replacement, bumpvalue)
local node = clicked_node[playername]
if not node or node.name ~= "imaging:canvas" then
notify.err(playername, "How did you end up here?")
return
end
local facedir = node.param2
local transform = get_facedir_transform(facedir)
local multi = bumpvalue / 255
function placeNode(x, y, paletteIndex)
local pos = {
x = math.floor(-grid.width / 2 + x + 0.5),
y = grid.height - y,
z = math.floor(-multi * paletteIndex + 0.5),
}
local newpos = vector.add(node.pos, apply_transform(pos, transform))
local newnode
if replacement then
newnode = { name = replacement }
else
newnode = {
name = "imaging:palette_" .. palette,
param2 = paletteIndex,
}
end
minetest.swap_node(newpos, newnode)
end
for y = 0, grid.height - 1 do
for x = 0, grid.width - 1 do
local paletteIndex = grid.rows[y][x]
if paletteIndex ~= false then
placeNode(x, y, paletteIndex)
end
end
end
end
imaging.init()
imaging.forms = {}
imaging.forms.main = smartfs.create("imaging.forms.main", function(state)
state:size(7.5, 8)
local paste_area = state:field(0.5, 0.5, 6.95, 3.5, "paste", "Paste Imaging data here")
paste_area:isMultiline(true)
paste_area:setCloseOnEnter(false)
local palettes = state:dropdown(0.2, 3.7, 5.2, 0, "palettes", {})
for name, palette in pairs(imaging.palettes) do
palettes:addItem(name)
end
if imaging.palettes.vga then
palettes:setSelectedItem("vga")
else
palettes:setSelected(1)
end
local generate_button = state:button(5.2, 3.6, 2, 1, "generate", "Build")
generate_button:onClick(imaging.generate)
generate_button:setClose(true)
local replacer = state:checkbox(0.2, 4.5, "replacer", "Build as:")
local replacement = state:field(0.5, 4.6, 5, 4.5, "replacement", "'air' or 'modname:nodename'")
replacement:setText("air")
replacement:setCloseOnEnter(false)
local bumpvalue = state:field(0.5, 5.8, 5, 4.5, "bumpvalue", "Bump value (zero or positive)")
bumpvalue:setText("0")
local close_button = state:button(5.2, 7, 2, 1, "close", "Close")
close_button:setClose(true)
end)

1440
lib/smartfs.lua Normal file

File diff suppressed because it is too large Load Diff

1
mod.conf Normal file
View File

@ -0,0 +1 @@
name = imaging

79
notify.lua Normal file
View File

@ -0,0 +1,79 @@
local mod_name = minetest.get_current_modname()
local huds = {}
local hud_timeout_seconds = 3
-- defaults
local position = { x = 0.1, y = 0.9}
local alignment = { x = 1, y = -1}
local normal_color = 0xFFFFFF
local warning_color = 0xFFFF00
local error_color = 0xDD0000
local direction = 0
local notify = {}
notify.__index = notify
setmetatable(notify, notify)
local function hud_remove(player, playername)
local hud = huds[playername]
if not hud then return end
if os.time() < hud_timeout_seconds + hud.time then
return
end
player:hud_remove(hud.id)
huds[playername] = nil
end
local function hud_create(player, message, params)
local playername = player:get_player_name()
local def = type(params) == "table" and params or {}
def.position = def.position or position
def.alignment = def.alignment or alignment
def.number = def.number or def.color or normal_color
def.color = nil
def.position = def.position or position
def.direction = def.direction or direction
def.text = message or def.text
def.hud_elem_type = def.hud_elem_type or "text"
def.name = mod_name .. "_feedback"
local id = player:hud_add(def)
huds[playername] = {
id = id,
time = os.time(),
}
end
notify.warn = function(player, message)
notify(player, message, {color = warning_color })
end
notify.warning = notify.warn
notify.err = function(player, message)
notify(player, message, {color = error_color })
end
notify.error = notify.err
notify.__call = function(self, player, message, params)
local playername
if type(player) == "string" then
playername = player
player = minetest.get_player_by_name(playername)
elseif player and player.get_player_name then
playername = player:get_player_name()
else
return
end
message = "[" .. mod_name .. "] " .. message
local hud = huds[playername]
if hud then
player:hud_remove(hud.id)
end
hud_create(player, message, params)
minetest.after(hud_timeout_seconds, function()
hud_remove(player, playername)
end)
end
return notify

BIN
screenshots/bump-build.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 KiB

BIN
screenshots/bump-result.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 687 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

BIN
screenshots/converter.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

BIN
textures/back.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

BIN
textures/black.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 B

BIN
textures/front.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
textures/palette-sepia.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
textures/palette-vga.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
textures/white.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 B