From b603e5f6e960008afde13116b7f1544fe01c10ca Mon Sep 17 00:00:00 2001 From: Viet281101 Date: Tue, 24 Feb 2026 08:38:56 +0700 Subject: [PATCH 1/3] update font --- 2D/style.css | 2 +- 3D/style.css | 2 +- assets/Pixellari.ttf | Bin 39908 -> 0 bytes assets/fonts/Pixellari.ttf | Bin 0 -> 53960 bytes .../generate_vietnamese_full.cpython-312.pyc | Bin 0 -> 9766 bytes assets/fonts/check_glyphs.py | 6 + assets/fonts/check_vietnamese_missing.py | 24 + assets/fonts/generate_vietnamese_full.py | 435 ++++++++++++++++++ js/main.js | 4 +- style.css | 2 +- 10 files changed, 471 insertions(+), 4 deletions(-) delete mode 100644 assets/Pixellari.ttf create mode 100644 assets/fonts/Pixellari.ttf create mode 100644 assets/fonts/__pycache__/generate_vietnamese_full.cpython-312.pyc create mode 100644 assets/fonts/check_glyphs.py create mode 100644 assets/fonts/check_vietnamese_missing.py create mode 100644 assets/fonts/generate_vietnamese_full.py diff --git a/2D/style.css b/2D/style.css index 91d13f6..0b084a9 100644 --- a/2D/style.css +++ b/2D/style.css @@ -1,7 +1,7 @@ @charset "UTF-8"; @font-face { font-family: 'Pixellari'; - src: url('../assets/Pixellari.ttf') format('truetype'); + src: url('../assets/fonts/Pixellari.ttf') format('truetype'); } * { font-family: 'Pixellari', sans-serif; diff --git a/3D/style.css b/3D/style.css index efacef6..2471d09 100644 --- a/3D/style.css +++ b/3D/style.css @@ -1,7 +1,7 @@ @charset "UTF-8"; @font-face { font-family: 'Pixellari'; - src: url('../assets/Pixellari.ttf') format('truetype'); + src: url('../assets/fonts/Pixellari.ttf') format('truetype'); } * { font-family: 'Pixellari', sans-serif; diff --git a/assets/Pixellari.ttf b/assets/Pixellari.ttf deleted file mode 100644 index 5a3a3c2b1104814e62854d43e4f5cffe51216707..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 39908 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 str: + return unicodedata.normalize("NFC", s) + + +def build_vietnamese_charset() -> List[str]: + seen = set() + ordered_unique: List[str] = [] + for ch in VIETNAMESE_REQUIRED: + if ch not in seen: + seen.add(ch) + ordered_unique.append(ch) + return ordered_unique + + +def glyph_exists(font, codepoint: int) -> bool: + try: + _ = font[codepoint].glyphname + return True + except Exception: + return False + + +def glyph_has_data(glyph) -> bool: + try: + if len(glyph.references) > 0: + return True + except Exception: + pass + try: + return not glyph.foreground.isEmpty() + except Exception: + return False + + +def copy_glyph_data(dst, src) -> None: + dst.clear() + dst.foreground = src.foreground.dup() + dst.width = src.width + + +def normalize_generated_glyph(glyph) -> None: + try: + glyph.round() + except Exception: + pass + try: + glyph.removeOverlap() + except Exception: + pass + try: + glyph.round() + except Exception: + pass + + +def contour_bbox(contour) -> Tuple[float, float, float, float]: + xs = [p.x for p in contour] + ys = [p.y for p in contour] + return (min(xs), min(ys), max(xs), max(ys)) + + +def filter_layer_contours(layer, keep_fn): + filtered = layer.dup() + to_delete: List[int] = [] + for i, contour in enumerate(filtered): + if not keep_fn(contour_bbox(contour)): + to_delete.append(i) + for i in reversed(to_delete): + del filtered[i] + return filtered + + +def extract_top_mark_layer(font, donor_name: str, donor_base_name: str, right_only: bool = False): + donor = font[donor_name] + donor_base = font[donor_base_name] + _, _, base_x2, base_y2 = donor_base.boundingBox() + base_x1, _, _, _ = donor_base.boundingBox() + base_center_x = (base_x1 + base_x2) / 2.0 + + donor_layer = donor.foreground.dup() + top_layer = filter_layer_contours( + donor_layer, + lambda bb: bb[3] > base_y2 + 1.0, + ) + if right_only: + top_layer = filter_layer_contours( + top_layer, + lambda bb: ((bb[0] + bb[2]) / 2.0) > base_center_x, + ) + return top_layer + + +def layer_bbox_center_x(layer) -> float: + x1, _, x2, _ = layer.boundingBox() + return (x1 + x2) / 2.0 + + +def add_layer_with_alignment( + target_layer, + mark_layer, + donor_base_glyph, + target_base_glyph, + y_anchor: str = "top", + scale_x: float = 1.0, + y_offset: float = 0.0, +): + moved = mark_layer.dup() + if scale_x != 1.0: + mx1, my1, mx2, my2 = moved.boundingBox() + mcx = (mx1 + mx2) / 2.0 + mcy = (my1 + my2) / 2.0 + moved.transform(psMat.translate(-mcx, -mcy)) + moved.transform(psMat.scale(scale_x, 1.0)) + moved.transform(psMat.translate(mcx, mcy)) + + donor_x1, donor_y1, donor_x2, donor_y2 = donor_base_glyph.boundingBox() + target_x1, target_y1, target_x2, target_y2 = target_base_glyph.boundingBox() + + dx = ((target_x1 + target_x2) / 2.0) - ((donor_x1 + donor_x2) / 2.0) + if y_anchor == "top": + dy = target_y2 - donor_y2 + y_offset + elif y_anchor == "bottom": + dy = target_y1 - donor_y1 + y_offset + else: + dy = y_offset + + moved.transform(psMat.translate(dx, dy)) + target_layer += moved + return target_layer + + +def add_dot_below(target_layer, base_glyph, period_glyph): + dot = period_glyph.foreground.dup() + dot_x1, dot_y1, dot_x2, _ = dot.boundingBox() + base_x1, base_y1, base_x2, _ = base_glyph.boundingBox() + dx = ((base_x1 + base_x2) / 2.0) - ((dot_x1 + dot_x2) / 2.0) + # Keep one 64-unit gap below baseline for Pixellari's pixel grid. + dy = (base_y1 - 192.0) - dot_y1 + dot.transform(psMat.translate(dx, dy)) + target_layer += dot + return target_layer + + +def add_hook_above(target_layer, base_glyph, comma_glyph, y_lift: float = 0.0): + hook = comma_glyph.foreground.dup() + # Mirror comma upward to get a compact hook shape that matches stroke style. + hook.transform(psMat.scale(1.0, -1.0)) + hook_x1, hook_y1, hook_x2, _ = hook.boundingBox() + base_x1, _, base_x2, base_y2 = base_glyph.boundingBox() + dx = ((base_x1 + base_x2) / 2.0 + 32.0) - ((hook_x1 + hook_x2) / 2.0) + # Keep hook close to the main top accent zone; avoids over-tall stacks. + dy = (base_y2 - 64.0 + y_lift) - hook_y1 + hook.transform(psMat.translate(dx, dy)) + target_layer += hook + return target_layer + + +def add_horn_right(target_layer, base_glyph, comma_glyph): + horn = comma_glyph.foreground.dup() + horn_x1, horn_y1, horn_x2, _ = horn.boundingBox() + base_x1, _, base_x2, base_y2 = base_glyph.boundingBox() + + # Place horn at top-right shoulder while keeping inside advance width. + dx = (base_x2 - 32.0) - ((horn_x1 + horn_x2) / 2.0) + dy = (base_y2 - 64.0) - horn_y1 + horn.transform(psMat.translate(dx, dy)) + target_layer += horn + return target_layer + + +def ensure_horn_base(font, ch: str, comma_glyph) -> bool: + cp = ord(ch) + if glyph_exists(font, cp) and glyph_has_data(font[cp]): + return True + + mapping = { + "ơ": "o", + "ư": "u", + "Ơ": "O", + "Ư": "U", + } + if ch not in mapping: + return False + + base_name = mapping[ch] + base = font[base_name] + + dst = font.createChar(cp) + layer = base.foreground.dup() + layer = add_horn_right(layer, base, comma_glyph) + + dst.clear() + dst.foreground = layer + dst.width = base.width + normalize_generated_glyph(dst) + return glyph_has_data(dst) + + +def parse_decomposition(ch: str) -> Tuple[str, List[str], List[str]]: + nfd = unicodedata.normalize("NFD", ch) + base = nfd[0] + shape_marks: List[str] = [] + tone_marks: List[str] = [] + for mark in nfd[1:]: + if mark in TONE_MARKS: + tone_marks.append(mark) + elif mark in {SHAPE_BREVE, SHAPE_CIRC, SHAPE_HORN}: + shape_marks.append(mark) + return base, shape_marks, tone_marks + + +def shape_base_char(base: str, shape_marks: Sequence[str]) -> str: + has_circ = SHAPE_CIRC in shape_marks + has_breve = SHAPE_BREVE in shape_marks + has_horn = SHAPE_HORN in shape_marks + if has_horn: + if base == "o": + return "ơ" + if base == "u": + return "ư" + if base == "O": + return "Ơ" + if base == "U": + return "Ư" + if has_breve: + return _nfc(base + SHAPE_BREVE) + if has_circ: + return _nfc(base + SHAPE_CIRC) + return base + + +def ensure_composed_vietnamese_glyph( + font, + ch: str, + mark_layers: Dict[str, object], + force_overwrite: bool, +) -> bool: + cp = ord(ch) + if (not force_overwrite) and (ch not in ALWAYS_REBUILD) and glyph_exists(font, cp) and glyph_has_data(font[cp]): + return True + + base, shape_marks, tone_marks = parse_decomposition(ch) + shaped = shape_base_char(base, shape_marks) + + # Ensure horn bases first because Pixellari does not ship o-horn/u-horn. + if SHAPE_HORN in shape_marks: + if not ensure_horn_base(font, shaped, mark_layers["comma"]): + return False + + shaped_cp = ord(shaped) + if not glyph_exists(font, shaped_cp) or not glyph_has_data(font[shaped_cp]): + return False + + base_glyph = font[shaped_cp] + # Vietnamese lowercase i drops the top dot only for top marks (grave/acute/hook/tilde). + if base == "i" and tone_marks and (tone_marks[0] != TONE_DOT): + base_glyph = font["dotlessi"] + base_width = base_glyph.width + dst = font.createChar(cp) + layer = base_glyph.foreground.dup() + + if tone_marks: + tone = tone_marks[0] + is_upper = base.isupper() + if tone == TONE_GRAVE: + mark = mark_layers["grave_upper"] if is_upper else mark_layers["grave_lower"] + donor = font["A"] if is_upper else font["a"] + y_offset = 64.0 if base == "I" else 0.0 + layer = add_layer_with_alignment(layer, mark, donor, base_glyph, y_anchor="top", y_offset=y_offset) + elif tone == TONE_ACUTE: + mark = mark_layers["acute_upper"] if is_upper else mark_layers["acute_lower"] + donor = font["A"] if is_upper else font["a"] + y_offset = 64.0 if base == "I" else 0.0 + layer = add_layer_with_alignment(layer, mark, donor, base_glyph, y_anchor="top", y_offset=y_offset) + elif tone == TONE_TILDE: + if base == "I": + mark = mark_layers["tilde_i_upper"] + donor = font["I"] + elif base == "i": + mark = mark_layers["tilde_lower"] + donor = font["a"] + else: + mark = mark_layers["tilde_upper"] if is_upper else mark_layers["tilde_lower"] + donor = font["A"] if is_upper else font["a"] + scale_x = 0.75 if base == "i" else 1.0 + y_offset = 64.0 if base == "I" else 0.0 + layer = add_layer_with_alignment( + layer, mark, donor, base_glyph, y_anchor="top", scale_x=scale_x, y_offset=y_offset + ) + elif tone == TONE_HOOK: + y_lift = 64.0 if base == "I" else 0.0 + layer = add_hook_above(layer, base_glyph, font["comma"], y_lift=y_lift) + elif tone == TONE_DOT: + layer = add_dot_below(layer, base_glyph, font["period"]) + + dst.clear() + dst.foreground = layer + dst.width = base_width + normalize_generated_glyph(dst) + return glyph_has_data(dst) + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Generate Vietnamese glyphs for Pixellari with manual pixel-grid composition." + ) + parser.add_argument( + "--input", + default="Pixellari.ttf", + help="Input TTF path (default: Pixellari.ttf)", + ) + parser.add_argument( + "--output", + default="Pixellari-Vietnamese.ttf", + help="Output TTF path (default: Pixellari-Vietnamese.ttf)", + ) + parser.add_argument( + "--force", + action="store_true", + help="Overwrite Vietnamese glyphs even if they already exist (useful when regenerating from a previously generated font).", + ) + args = parser.parse_args() + + if not os.path.exists(args.input): + print(f"Input font not found: {args.input}", file=sys.stderr) + return 1 + + font = fontforge.open(args.input) + + mark_layers: Dict[str, object] = { + "grave_lower": extract_top_mark_layer(font, "agrave", "a"), + "acute_lower": extract_top_mark_layer(font, "aacute", "a"), + "tilde_lower": extract_top_mark_layer(font, "atilde", "a"), + "grave_upper": extract_top_mark_layer(font, "Agrave", "A"), + "acute_upper": extract_top_mark_layer(font, "Aacute", "A"), + "tilde_upper": extract_top_mark_layer(font, "Atilde", "A"), + "tilde_i_upper": extract_top_mark_layer(font, "Itilde", "I"), + "comma": font["comma"], + } + + required = build_vietnamese_charset() + missing_before = [] + for ch in required: + cp = ord(ch) + if (not glyph_exists(font, cp)) or (not glyph_has_data(font[cp])): + missing_before.append(ch) + + created: List[str] = [] + failed: List[str] = [] + for ch in required: + if ensure_composed_vietnamese_glyph(font, ch, mark_layers, force_overwrite=args.force): + created.append(ch) + else: + failed.append(ch) + + still_missing = [] + for ch in required: + cp = ord(ch) + if (not glyph_exists(font, cp)) or (not glyph_has_data(font[cp])): + still_missing.append(ch) + + font.generate(args.output) + font.close() + + print(f"Input: {args.input}") + print(f"Output: {args.output}") + print(f"Vietnamese required: {len(required)}") + print(f"Missing before: {len(missing_before)}") + print(f"Created now: {len(created)}") + print(f"Failed to build: {len(failed)}") + print(f"Still missing after generate: {len(still_missing)}") + + if still_missing: + print("Missing codepoints:") + for ch in still_missing: + cp = ord(ch) + print(f" U+{cp:04X} {ch} {unicodedata.name(ch, 'UNKNOWN')}") + return 2 + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/js/main.js b/js/main.js index fd0d83a..108d372 100644 --- a/js/main.js +++ b/js/main.js @@ -122,7 +122,9 @@ class MainApp { this.ctx.textAlign = 'center'; this.ctx.fillText('Research polyominoes &', this.canvas.width / 2, 200); this.ctx.fillText('Propose solving solutions.', this.canvas.width / 2, 230); - this.ctx.fillText('Mini Polyominoes Games :', this.canvas.width / 2, this.miniGamesLabelY); + if (this.canvas.width >= 700) { + this.ctx.fillText('Mini Polyominoes Games :', this.canvas.width / 2, this.miniGamesLabelY); + } this.buttons.forEach((button) => this.drawButton(button)); } diff --git a/style.css b/style.css index 627fde8..24f298f 100644 --- a/style.css +++ b/style.css @@ -1,7 +1,7 @@ @charset "UTF-8"; @font-face { font-family: 'Pixellari'; - src: url('./assets/Pixellari.ttf') format('truetype'); + src: url('./assets/fonts/Pixellari.ttf') format('truetype'); } html, From d26bc16ddf9bc5637073a2cac7c438b4065bd201 Mon Sep 17 00:00:00 2001 From: Viet281101 Date: Tue, 24 Feb 2026 09:04:25 +0700 Subject: [PATCH 2/3] move icons --- 2D/js/main.js | 12 ++--- 2D/js/popup/grid.js | 2 +- 2D/js/popup/setting.js | 2 +- 2D/js/popup/solve.js | 2 +- 2D/js/popup/tutorial.js | 8 +-- 2D/js/toolbar.js | 14 +++--- 3D/js/constants.js | 26 +++++----- 3D/js/popup/cube/view.js | 16 ++++-- 3D/js/popup/grid.js | 7 ++- 3D/js/popup/setting.js | 2 +- 3D/js/popup/solve.js | 7 ++- 3D/js/popup/tutorial.js | 8 +-- 3D/js/toolbar.js | 46 +++++++++--------- .../arrow_left.png} | Bin .../arrow_right.png} | Bin assets/{ic_blacken.png => icons/blacken.png} | Bin .../blacken_cell.png} | Bin assets/{ic_close.png => icons/close.png} | Bin assets/{ic_draw.png => icons/draw.png} | Bin .../{ic_duplicate.png => icons/duplicate.png} | Bin assets/{ic_flip.png => icons/flip.png} | Bin assets/{ic_github.png => icons/github.png} | Bin assets/{ic_home.png => icons/home.png} | Bin assets/{ => icons}/info.png | Bin .../invert_blacken.png} | Bin .../left_click.png} | Bin assets/{ic_plus.png => icons/plus.png} | Bin .../{ic_question.png => icons/question.png} | Bin .../random_blacken_cell.png} | Bin assets/{ic_reset.png => icons/reset.png} | Bin .../right_click.png} | Bin .../rotate_left.png} | Bin .../rotate_right.png} | Bin assets/{ic_select.png => icons/select.png} | Bin assets/{ic_setting.png => icons/setting.png} | Bin assets/{ic_shuffle.png => icons/shuffle.png} | Bin .../{ic_solution.png => icons/solution.png} | Bin assets/{ic_solving.png => icons/solving.png} | Bin assets/{ic_split.png => icons/split.png} | Bin assets/{ic_table.png => icons/table.png} | Bin assets/{ic_trash.png => icons/trash.png} | Bin assets/{ic_whiten.png => icons/whiten.png} | Bin js/main.js | 2 +- js/tetris.js | 20 ++++---- 44 files changed, 92 insertions(+), 82 deletions(-) rename assets/{ic_arrow_left.png => icons/arrow_left.png} (100%) rename assets/{ic_arrow_right.png => icons/arrow_right.png} (100%) rename assets/{ic_blacken.png => icons/blacken.png} (100%) rename assets/{ic_blacken_cell.png => icons/blacken_cell.png} (100%) rename assets/{ic_close.png => icons/close.png} (100%) rename assets/{ic_draw.png => icons/draw.png} (100%) rename assets/{ic_duplicate.png => icons/duplicate.png} (100%) rename assets/{ic_flip.png => icons/flip.png} (100%) rename assets/{ic_github.png => icons/github.png} (100%) rename assets/{ic_home.png => icons/home.png} (100%) rename assets/{ => icons}/info.png (100%) rename assets/{ic_invert_blacken.png => icons/invert_blacken.png} (100%) rename assets/{ic_left_click.png => icons/left_click.png} (100%) rename assets/{ic_plus.png => icons/plus.png} (100%) rename assets/{ic_question.png => icons/question.png} (100%) rename assets/{ic_random_blacken_cell.png => icons/random_blacken_cell.png} (100%) rename assets/{ic_reset.png => icons/reset.png} (100%) rename assets/{ic_right_click.png => icons/right_click.png} (100%) rename assets/{ic_rotate_left.png => icons/rotate_left.png} (100%) rename assets/{ic_rotate_right.png => icons/rotate_right.png} (100%) rename assets/{ic_select.png => icons/select.png} (100%) rename assets/{ic_setting.png => icons/setting.png} (100%) rename assets/{ic_shuffle.png => icons/shuffle.png} (100%) rename assets/{ic_solution.png => icons/solution.png} (100%) rename assets/{ic_solving.png => icons/solving.png} (100%) rename assets/{ic_split.png => icons/split.png} (100%) rename assets/{ic_table.png => icons/table.png} (100%) rename assets/{ic_trash.png => icons/trash.png} (100%) rename assets/{ic_whiten.png => icons/whiten.png} (100%) diff --git a/2D/js/main.js b/2D/js/main.js index 0371aaa..275a96c 100644 --- a/2D/js/main.js +++ b/2D/js/main.js @@ -24,12 +24,12 @@ class MainApp { duplicate: new Image(), trash: new Image(), }; - const as = '../assets/'; - this.icons.flip.src = as + 'ic_flip.png'; - this.icons.rotateLeft.src = as + 'ic_rotate_left.png'; - this.icons.rotateRight.src = as + 'ic_rotate_right.png'; - this.icons.duplicate.src = as + 'ic_duplicate.png'; - this.icons.trash.src = as + 'ic_trash.png'; + const as = '../assets/icons/'; + this.icons.flip.src = as + 'flip.png'; + this.icons.rotateLeft.src = as + 'rotate_left.png'; + this.icons.rotateRight.src = as + 'rotate_right.png'; + this.icons.duplicate.src = as + 'duplicate.png'; + this.icons.trash.src = as + 'trash.png'; this.gridBoard = new GridBoard(this.canvas, this.gridSize, this.rows, this.cols); this.guiController = new GUIController(this); this.toolbar = new Toolbar(this); diff --git a/2D/js/popup/grid.js b/2D/js/popup/grid.js index 0f23a75..b0d5935 100644 --- a/2D/js/popup/grid.js +++ b/2D/js/popup/grid.js @@ -36,7 +36,7 @@ export function showGridPopup(toolbar) { const assetsReady = Promise.all( iconRows.map((row) => - loadImage(`../assets/ic_${row.icon}.png`).then((image) => { + loadImage(`../assets/icons/${row.icon}.png`).then((image) => { if (image) iconMap.set(row.icon, image); }) ) diff --git a/2D/js/popup/setting.js b/2D/js/popup/setting.js index 34f750e..059f306 100644 --- a/2D/js/popup/setting.js +++ b/2D/js/popup/setting.js @@ -36,7 +36,7 @@ export function showSettingsPopup(toolbar) { const assetsReady = Promise.all( iconRows.map((row) => - loadImage(`../assets/ic_${row.icon}.png`).then((image) => { + loadImage(`../assets/icons/${row.icon}.png`).then((image) => { if (image) iconMap.set(row.icon, image); }) ) diff --git a/2D/js/popup/solve.js b/2D/js/popup/solve.js index e73df5f..0d446f7 100644 --- a/2D/js/popup/solve.js +++ b/2D/js/popup/solve.js @@ -75,7 +75,7 @@ export function showSolvePopup(toolbar) { const assetsReady = Promise.all( iconRows.map((row) => - loadImage(`../assets/ic_${row.icon}.png`).then((image) => { + loadImage(`../assets/icons/${row.icon}.png`).then((image) => { if (image) iconMap.set(row.icon, image); }) ) diff --git a/2D/js/popup/tutorial.js b/2D/js/popup/tutorial.js index 6a45ee7..2686b3e 100644 --- a/2D/js/popup/tutorial.js +++ b/2D/js/popup/tutorial.js @@ -85,8 +85,8 @@ export function showTutorialPopup(toolbar) { const iconMap = new Map(); const allIconPaths = [ - ...rows.filter((row) => row.icon).map((row) => `ic_${row.icon}.png`), - ...subIcons.map((icon) => `ic_${icon.path}.png`), + ...rows.filter((row) => row.icon).map((row) => `icons/${row.icon}.png`), + ...subIcons.map((icon) => `icons/${icon.path}.png`), ]; const uniqueIconPaths = [...new Set(allIconPaths)]; @@ -160,7 +160,7 @@ export function showTutorialPopup(toolbar) { } if (row.icon) { - const icon = iconMap.get(`ic_${row.icon}.png`); + const icon = iconMap.get(`icons/${row.icon}.png`); if (icon) { ctx.drawImage(icon, popup.width - 64, y - 14, 50, 50); } @@ -193,7 +193,7 @@ export function showTutorialPopup(toolbar) { }); subIcons.forEach((subIcon) => { - const subIconImage = iconMap.get(`ic_${subIcon.path}.png`); + const subIconImage = iconMap.get(`icons/${subIcon.path}.png`); if (subIconImage) { ctx.drawImage(subIconImage, subIcon.x, subIcon.y, 25, 25); } diff --git a/2D/js/toolbar.js b/2D/js/toolbar.js index 95e4b7f..6bca0bc 100644 --- a/2D/js/toolbar.js +++ b/2D/js/toolbar.js @@ -50,7 +50,7 @@ export class Toolbar { } preloadIcons() { - this.homeImage = this.preloadImage('../assets/ic_home.png'); + this.homeImage = this.preloadImage('../assets/icons/home.png'); this.buttons.forEach((button) => { button.image = this.preloadImage(button.icon); }); @@ -60,31 +60,31 @@ export class Toolbar { return [ { name: 'Create Polyomino', - icon: '../assets/ic_plus.png', + icon: '../assets/icons/plus.png', action: () => this.togglePopup('polyomino'), description: 'To select available Polyomino blocks.\nAdd directly to canvas.', }, { name: 'Grid Settings', - icon: '../assets/ic_table.png', + icon: '../assets/icons/table.png', action: () => this.togglePopup('grid'), description: 'To change the grid settings.\nAdjust rows, columns, and size.', }, { name: 'Solving Polyomino', - icon: '../assets/ic_solving.png', + icon: '../assets/icons/solving.png', action: () => this.togglePopup('solve'), description: 'To solve the polyomino puzzle.\nUse different algorithms to solve.', }, { name: 'Tutorial', - icon: '../assets/ic_question.png', + icon: '../assets/icons/question.png', action: () => this.togglePopup('tutorial'), description: 'To view the tutorial.\nLearn how to use the application.', }, { name: 'Settings', - icon: '../assets/ic_setting.png', + icon: '../assets/icons/setting.png', action: () => this.togglePopup('settings'), description: 'To adjust application settings.\nChange colors, tooltips, and more.', }, @@ -408,7 +408,7 @@ export class Toolbar { } const closeIcon = new Image(); - closeIcon.src = '../assets/ic_close.png'; + closeIcon.src = '../assets/icons/close.png'; closeIcon.style.position = 'fixed'; Object.assign(closeIcon.style, { cursor: 'pointer', diff --git a/3D/js/constants.js b/3D/js/constants.js index 9a5155f..ad6da7b 100644 --- a/3D/js/constants.js +++ b/3D/js/constants.js @@ -382,43 +382,43 @@ export const TUTORIAL_POPUP_ROWS = [ underline: true, icon: 'plus', description: - "Click this icon to open a menu.\n\nSteps to create a Polycube:\n\n1. Enter the desired number of cubes in the 'N° cubes' input field.\n2. Enter the position coordinates (x, y, z) for the Polycube.\n3. Use the 'Previous' [ic_arrow_left] and 'Next' [ic_arrow_right] buttons to navigate through highlighted cubes.\n4. Click 'Select' [ic_select] to add the highlighted cube to your Polycube.\n5. Click 'Undo' [ic_reset] to remove the last added cube.\n6. Click 'Create' to finalize and create the Polycube.\n7. Click 'Clear' to reset the Polycube creation and start over.\n8. Use the 'Info' button to display current Polycube information.", + "Click this icon to open a menu.\n\nSteps to create a Polycube:\n\n1. Enter the desired number of cubes in the 'N° cubes' input field.\n2. Enter the position coordinates (x, y, z) for the Polycube.\n3. Use the 'Previous' [icons/arrow_left] and 'Next' [icons/arrow_right] buttons to navigate through highlighted cubes.\n4. Click 'Select' [icons/select] to add the highlighted cube to your Polycube.\n5. Click 'Undo' [icons/reset] to remove the last added cube.\n6. Click 'Create' to finalize and create the Polycube.\n7. Click 'Clear' to reset the Polycube creation and start over.\n8. Use the 'Info' button to display current Polycube information.", }, { label: '2) Manipulate the Polycubes :', underline: true, description: - "Click to any Polycube on the scene to select it.\n\nAfter selecting a Polycube, you can:\n\n1. Use the [ic_left_click] 'Left Mouse Button' to rotate the Polycube.\n2. Use the [ic_right_click] 'Right Mouse Button' to move the Polycube.\n\nCustom visible views of Polycubes with DAT.GUI :\n\n1. Click 'polycubeVisible' to show or hide the selected Polycube on the scene.\n2. Click 'allCubesVisible' to show or hide all Polycubes on the scene.", + "Click to any Polycube on the scene to select it.\n\nAfter selecting a Polycube, you can:\n\n1. Use the [icons/left_click] 'Left Mouse Button' to rotate the Polycube.\n2. Use the [icons/right_click] 'Right Mouse Button' to move the Polycube.\n\nCustom visible views of Polycubes with DAT.GUI :\n\n1. Click 'polycubeVisible' to show or hide the selected Polycube on the scene.\n2. Click 'allCubesVisible' to show or hide all Polycubes on the scene.", }, { label: '3) Grid Board Settings', underline: true, icon: 'table', description: - "This menu lets you delete and or create the new grid.\n\n1. Click [ic_trash] 'Delete current grid' to delete the grid.\n2. Enter (x, y, z) sizes for the grid. Click [ic_draw] to create a new grid.", + "This menu lets you delete and or create the new grid.\n\n1. Click [icons/trash] 'Delete current grid' to delete the grid.\n2. Enter (x, y, z) sizes for the grid. Click [icons/draw] to create a new grid.", }, { label: '4) Manipulate the Grid Board :', underline: true, description: - "You can only control the grid through camera perspective with Orbit Controls :\n\n1. Use the [ic_left_click] 'Left Mouse Button' to rotate the grid.\n2. Use the [ic_right_click] 'Right Mouse Button' to move the grid.\n3. Use the 'Center Mouse Button' to zoom the grid.\n\nCustom visible views of grid with DAT.GUI :\n\n1. Click 'showInnerGrid' to show the grid's inner grid.\n2. Click 'showOuterGrid' to show the grid's outer grid. But this is already shown by default.", + "You can only control the grid through camera perspective with Orbit Controls :\n\n1. Use the [icons/left_click] 'Left Mouse Button' to rotate the grid.\n2. Use the [icons/right_click] 'Right Mouse Button' to move the grid.\n3. Use the 'Center Mouse Button' to zoom the grid.\n\nCustom visible views of grid with DAT.GUI :\n\n1. Click 'showInnerGrid' to show the grid's inner grid.\n2. Click 'showOuterGrid' to show the grid's outer grid. But this is already shown by default.", }, { label: '5) Use Settings :', underline: true, icon: 'setting', description: - "In this menu, you can:\n\n1. Click [ic_trash] 'Delete Selected Polycube' to delete the Polycube that you are currently selected.", + "In this menu, you can:\n\n1. Click [icons/trash] 'Delete Selected Polycube' to delete the Polycube that you are currently selected.", }, ]; export const TUTORIAL_ICON_MAP = { - '[ic_arrow_left]': '../assets/ic_arrow_left.png', - '[ic_arrow_right]': '../assets/ic_arrow_right.png', - '[ic_select]': '../assets/ic_select.png', - '[ic_reset]': '../assets/ic_reset.png', - '[ic_left_click]': '../assets/ic_left_click.png', - '[ic_right_click]': '../assets/ic_right_click.png', - '[ic_trash]': '../assets/ic_trash.png', - '[ic_draw]': '../assets/ic_draw.png', + '[icons/arrow_left]': '../assets/icons/arrow_left.png', + '[icons/arrow_right]': '../assets/icons/arrow_right.png', + '[icons/select]': '../assets/icons/select.png', + '[icons/reset]': '../assets/icons/reset.png', + '[icons/left_click]': '../assets/icons/left_click.png', + '[icons/right_click]': '../assets/icons/right_click.png', + '[icons/trash]': '../assets/icons/trash.png', + '[icons/draw]': '../assets/icons/draw.png', }; diff --git a/3D/js/popup/cube/view.js b/3D/js/popup/cube/view.js index e814ea5..6406a70 100644 --- a/3D/js/popup/cube/view.js +++ b/3D/js/popup/cube/view.js @@ -10,7 +10,10 @@ export class CubePopupView { } render({ onNChange, onNavigate, onInfo, onClear, onCreate }) { - this.popupContainer = this.toolbar.createPopupContainer('cubePopup', this.toolbar.buttons[0].name); + this.popupContainer = this.toolbar.createPopupContainer( + 'cubePopup', + this.toolbar.buttons[0].name + ); const popup = this.popupContainer.querySelector('canvas'); const ctx = popup.getContext('2d'); @@ -19,7 +22,10 @@ export class CubePopupView { this.renderFormFields(onNChange); const previewCanvas = this.createPreviewCanvas(); - this.createTextZone(popup.width - CUBE_POPUP_CONSTANTS.TEXT_ZONE.WIDTH_OFFSET, 'Polycube Info...'); + this.createTextZone( + popup.width - CUBE_POPUP_CONSTANTS.TEXT_ZONE.WIDTH_OFFSET, + 'Polycube Info...' + ); this.createNavigationButtons(onNavigate); this.createActionButtons({ onInfo, onClear, onCreate }); @@ -38,7 +44,9 @@ export class CubePopupView { FORM_FIELD_LAYOUT.forEach((field, index) => { const rowY = - field.row === 1 ? startY - inputOffset + rowOffset : startY + rowSpacing - inputOffset + rowOffset; + field.row === 1 + ? startY - inputOffset + rowOffset + : startY + rowSpacing - inputOffset + rowOffset; this.createLabel(field.label, field.labelX, rowY); const input = this.createInputField(field.inputX, rowY, field.defaultValue); @@ -147,7 +155,7 @@ export class CubePopupView { buttonContainer.appendChild(label); const icon = document.createElement('img'); - icon.src = `../assets/ic_${item.icon}.png`; + icon.src = `../assets/icons/${item.icon}.png`; icon.width = CUBE_POPUP_CONSTANTS.NAV.ICON_SIZE; icon.height = CUBE_POPUP_CONSTANTS.NAV.ICON_SIZE; icon.style.cursor = 'pointer'; diff --git a/3D/js/popup/grid.js b/3D/js/popup/grid.js index d3390c6..5343a85 100644 --- a/3D/js/popup/grid.js +++ b/3D/js/popup/grid.js @@ -13,7 +13,10 @@ export class GridPopup { } render() { - this.popupContainer = this.toolbar.createPopupContainer('gridPopup', this.toolbar.buttons[1].name); + this.popupContainer = this.toolbar.createPopupContainer( + 'gridPopup', + this.toolbar.buttons[1].name + ); this.popup = this.popupContainer.querySelector('canvas'); const ctx = this.popup.getContext('2d'); ctx.fillStyle = GRID_POPUP_CONSTANTS.BACKGROUND_COLOR; @@ -94,7 +97,7 @@ export class GridPopup { createIconButton(y, iconName, onClick) { const { LAYOUT } = GRID_POPUP_CONSTANTS; const iconButton = document.createElement('img'); - iconButton.src = `../assets/ic_${iconName}.png`; + iconButton.src = `../assets/icons/${iconName}.png`; applyStyles(iconButton, { position: 'absolute', left: `${this.popup.width - LAYOUT.ICON_LEFT_OFFSET}px`, diff --git a/3D/js/popup/setting.js b/3D/js/popup/setting.js index 3e677b5..cb3e11d 100644 --- a/3D/js/popup/setting.js +++ b/3D/js/popup/setting.js @@ -63,7 +63,7 @@ export class SettingsPopup { this.popupContainer.appendChild(rowLabel); const iconButton = document.createElement('img'); - iconButton.src = `../assets/ic_${SETTINGS_POPUP_CONTENT.ACTION_ICON}.png`; + iconButton.src = `../assets/icons/${SETTINGS_POPUP_CONTENT.ACTION_ICON}.png`; applyStyles(iconButton, { position: 'absolute', left: `${this.popup.width - LAYOUT.ICON_LEFT_OFFSET}px`, diff --git a/3D/js/popup/solve.js b/3D/js/popup/solve.js index 5bcb295..aba11fd 100644 --- a/3D/js/popup/solve.js +++ b/3D/js/popup/solve.js @@ -12,7 +12,10 @@ export class SolvePopup { } render() { - this.popupContainer = this.toolbar.createPopupContainer('solvePopup', this.toolbar.buttons[2].name); + this.popupContainer = this.toolbar.createPopupContainer( + 'solvePopup', + this.toolbar.buttons[2].name + ); this.popup = this.popupContainer.querySelector('canvas'); const ctx = this.popup.getContext('2d'); const { LAYOUT, FONT, COLORS } = SOLVE_POPUP_CONSTANTS; @@ -69,7 +72,7 @@ export class SolvePopup { if (row.icon) { const iconButton = document.createElement('img'); - iconButton.src = `../assets/ic_${row.icon}.png`; + iconButton.src = `../assets/icons/${row.icon}.png`; applyStyles(iconButton, { position: 'absolute', right: '0', diff --git a/3D/js/popup/tutorial.js b/3D/js/popup/tutorial.js index 549f151..da19dd5 100644 --- a/3D/js/popup/tutorial.js +++ b/3D/js/popup/tutorial.js @@ -1,8 +1,4 @@ -import { - TUTORIAL_POPUP_CONSTANTS, - TUTORIAL_POPUP_ROWS, - TUTORIAL_ICON_MAP, -} from '../constants.js'; +import { TUTORIAL_POPUP_CONSTANTS, TUTORIAL_POPUP_ROWS, TUTORIAL_ICON_MAP } from '../constants.js'; function applyStyles(element, styles) { Object.assign(element.style, styles); @@ -60,7 +56,7 @@ export class TutorialPopup { if (row.icon) { const icon = document.createElement('img'); - icon.src = `../assets/ic_${row.icon}.png`; + icon.src = `../assets/icons/${row.icon}.png`; applyStyles(icon, { position: 'absolute', right: '0', diff --git a/3D/js/toolbar.js b/3D/js/toolbar.js index 02e19ee..fd3b5ec 100644 --- a/3D/js/toolbar.js +++ b/3D/js/toolbar.js @@ -81,31 +81,31 @@ export class Toolbar { return [ { name: 'Create Polycube', - icon: '../assets/ic_plus.png', + icon: '../assets/icons/plus.png', action: () => this.togglePopup('cube'), description: 'To create a new cube and add it to the scene.', }, { name: 'Grid Settings', - icon: '../assets/ic_table.png', + icon: '../assets/icons/table.png', action: () => this.togglePopup('grid'), description: 'To change the grid settings.', }, { name: 'Solving Polycube', - icon: '../assets/ic_solving.png', + icon: '../assets/icons/solving.png', action: () => this.togglePopup('solve'), description: 'To solve the polycube puzzle.\nUse different algorithms to solve.', }, { name: 'Tutorial', - icon: '../assets/ic_question.png', + icon: '../assets/icons/question.png', action: () => this.togglePopup('tutorial'), description: 'To view the tutorial.\nLearn how to use the application.', }, { name: 'Settings', - icon: '../assets/ic_setting.png', + icon: '../assets/icons/setting.png', action: () => this.togglePopup('settings'), description: 'To adjust application settings.\nChange colors, tooltips, and more.', }, @@ -155,14 +155,14 @@ export class Toolbar { TOOLBAR_CONSTANTS.BUTTON_ICON_SIZE, TOOLBAR_CONSTANTS.BUTTON_ICON_SIZE, () => { - this.ctx.strokeStyle = '#fff'; - this.ctx.strokeRect( - x - TOOLBAR_CONSTANTS.BUTTON_STROKE_OFFSET, - TOOLBAR_CONSTANTS.BUTTON_STROKE_OFFSET, - TOOLBAR_CONSTANTS.BUTTON_OUTER_SIZE, - TOOLBAR_CONSTANTS.BUTTON_OUTER_SIZE - ); - } + this.ctx.strokeStyle = '#fff'; + this.ctx.strokeRect( + x - TOOLBAR_CONSTANTS.BUTTON_STROKE_OFFSET, + TOOLBAR_CONSTANTS.BUTTON_STROKE_OFFSET, + TOOLBAR_CONSTANTS.BUTTON_OUTER_SIZE, + TOOLBAR_CONSTANTS.BUTTON_OUTER_SIZE + ); + } ); }); } @@ -184,14 +184,14 @@ export class Toolbar { TOOLBAR_CONSTANTS.BUTTON_ICON_SIZE, TOOLBAR_CONSTANTS.BUTTON_ICON_SIZE, () => { - this.ctx.strokeStyle = '#fff'; - this.ctx.strokeRect( - TOOLBAR_CONSTANTS.BUTTON_STROKE_OFFSET, - y - TOOLBAR_CONSTANTS.BUTTON_STROKE_OFFSET, - TOOLBAR_CONSTANTS.BUTTON_OUTER_SIZE, - TOOLBAR_CONSTANTS.BUTTON_OUTER_SIZE - ); - } + this.ctx.strokeStyle = '#fff'; + this.ctx.strokeRect( + TOOLBAR_CONSTANTS.BUTTON_STROKE_OFFSET, + y - TOOLBAR_CONSTANTS.BUTTON_STROKE_OFFSET, + TOOLBAR_CONSTANTS.BUTTON_OUTER_SIZE, + TOOLBAR_CONSTANTS.BUTTON_OUTER_SIZE + ); + } ); }); } @@ -320,7 +320,7 @@ export class Toolbar { } addHomeButton() { - this.drawIcon('../assets/ic_home.png', 10, 10, 30, 30, () => { + this.drawIcon('../assets/icons/home.png', 10, 10, 30, 30, () => { this.ctx.strokeStyle = '#fff'; this.ctx.strokeRect(5, 5, 40, 40); }); @@ -397,7 +397,7 @@ export class Toolbar { document.body.removeChild(this.currentCloseIcon); } const closeIcon = new Image(); - closeIcon.src = '../assets/ic_close.png'; + closeIcon.src = '../assets/icons/close.png'; closeIcon.style.position = 'fixed'; closeIcon.style.top = this.isMobile ? '56px' : '10px'; closeIcon.style.left = this.isMobile ? 'calc(50% + 162px)' : '400px'; diff --git a/assets/ic_arrow_left.png b/assets/icons/arrow_left.png similarity index 100% rename from assets/ic_arrow_left.png rename to assets/icons/arrow_left.png diff --git a/assets/ic_arrow_right.png b/assets/icons/arrow_right.png similarity index 100% rename from assets/ic_arrow_right.png rename to assets/icons/arrow_right.png diff --git a/assets/ic_blacken.png b/assets/icons/blacken.png similarity index 100% rename from assets/ic_blacken.png rename to assets/icons/blacken.png diff --git a/assets/ic_blacken_cell.png b/assets/icons/blacken_cell.png similarity index 100% rename from assets/ic_blacken_cell.png rename to assets/icons/blacken_cell.png diff --git a/assets/ic_close.png b/assets/icons/close.png similarity index 100% rename from assets/ic_close.png rename to assets/icons/close.png diff --git a/assets/ic_draw.png b/assets/icons/draw.png similarity index 100% rename from assets/ic_draw.png rename to assets/icons/draw.png diff --git a/assets/ic_duplicate.png b/assets/icons/duplicate.png similarity index 100% rename from assets/ic_duplicate.png rename to assets/icons/duplicate.png diff --git a/assets/ic_flip.png b/assets/icons/flip.png similarity index 100% rename from assets/ic_flip.png rename to assets/icons/flip.png diff --git a/assets/ic_github.png b/assets/icons/github.png similarity index 100% rename from assets/ic_github.png rename to assets/icons/github.png diff --git a/assets/ic_home.png b/assets/icons/home.png similarity index 100% rename from assets/ic_home.png rename to assets/icons/home.png diff --git a/assets/info.png b/assets/icons/info.png similarity index 100% rename from assets/info.png rename to assets/icons/info.png diff --git a/assets/ic_invert_blacken.png b/assets/icons/invert_blacken.png similarity index 100% rename from assets/ic_invert_blacken.png rename to assets/icons/invert_blacken.png diff --git a/assets/ic_left_click.png b/assets/icons/left_click.png similarity index 100% rename from assets/ic_left_click.png rename to assets/icons/left_click.png diff --git a/assets/ic_plus.png b/assets/icons/plus.png similarity index 100% rename from assets/ic_plus.png rename to assets/icons/plus.png diff --git a/assets/ic_question.png b/assets/icons/question.png similarity index 100% rename from assets/ic_question.png rename to assets/icons/question.png diff --git a/assets/ic_random_blacken_cell.png b/assets/icons/random_blacken_cell.png similarity index 100% rename from assets/ic_random_blacken_cell.png rename to assets/icons/random_blacken_cell.png diff --git a/assets/ic_reset.png b/assets/icons/reset.png similarity index 100% rename from assets/ic_reset.png rename to assets/icons/reset.png diff --git a/assets/ic_right_click.png b/assets/icons/right_click.png similarity index 100% rename from assets/ic_right_click.png rename to assets/icons/right_click.png diff --git a/assets/ic_rotate_left.png b/assets/icons/rotate_left.png similarity index 100% rename from assets/ic_rotate_left.png rename to assets/icons/rotate_left.png diff --git a/assets/ic_rotate_right.png b/assets/icons/rotate_right.png similarity index 100% rename from assets/ic_rotate_right.png rename to assets/icons/rotate_right.png diff --git a/assets/ic_select.png b/assets/icons/select.png similarity index 100% rename from assets/ic_select.png rename to assets/icons/select.png diff --git a/assets/ic_setting.png b/assets/icons/setting.png similarity index 100% rename from assets/ic_setting.png rename to assets/icons/setting.png diff --git a/assets/ic_shuffle.png b/assets/icons/shuffle.png similarity index 100% rename from assets/ic_shuffle.png rename to assets/icons/shuffle.png diff --git a/assets/ic_solution.png b/assets/icons/solution.png similarity index 100% rename from assets/ic_solution.png rename to assets/icons/solution.png diff --git a/assets/ic_solving.png b/assets/icons/solving.png similarity index 100% rename from assets/ic_solving.png rename to assets/icons/solving.png diff --git a/assets/ic_split.png b/assets/icons/split.png similarity index 100% rename from assets/ic_split.png rename to assets/icons/split.png diff --git a/assets/ic_table.png b/assets/icons/table.png similarity index 100% rename from assets/ic_table.png rename to assets/icons/table.png diff --git a/assets/ic_trash.png b/assets/icons/trash.png similarity index 100% rename from assets/ic_trash.png rename to assets/icons/trash.png diff --git a/assets/ic_whiten.png b/assets/icons/whiten.png similarity index 100% rename from assets/ic_whiten.png rename to assets/icons/whiten.png diff --git a/js/main.js b/js/main.js index 108d372..5f01471 100644 --- a/js/main.js +++ b/js/main.js @@ -40,7 +40,7 @@ class MainApp { ]; this.usedColors = []; this.iconImage = new Image(); - this.iconImage.src = './assets/ic_arrow_right.png'; + this.iconImage.src = './assets/icons/arrow_right.png'; this.targetFps = 60; this.frameDurationMs = 1000 / this.targetFps; this.maxDeltaMs = 100; diff --git a/js/tetris.js b/js/tetris.js index 40c63b8..90b3fad 100644 --- a/js/tetris.js +++ b/js/tetris.js @@ -12,11 +12,11 @@ class Tetris { this.tetriminos = TETRIMINOS; this.controlItems = [ - { name: 'ic_arrow_left.png', action: () => this.move(-1), holdable: true }, - { name: 'ic_arrow_right.png', action: () => this.move(1), holdable: true }, - { name: 'ic_rotate_left.png', action: () => this.rotateLeft(), holdable: false }, - { name: 'ic_rotate_right.png', action: () => this.rotateRight(), holdable: false }, - { name: 'ic_home.png', action: () => this.goHome(), holdable: false }, + { name: 'icons/arrow_left.png', action: () => this.move(-1), holdable: true }, + { name: 'icons/arrow_right.png', action: () => this.move(1), holdable: true }, + { name: 'icons/rotate_left.png', action: () => this.rotateLeft(), holdable: false }, + { name: 'icons/rotate_right.png', action: () => this.rotateRight(), holdable: false }, + { name: 'icons/home.png', action: () => this.goHome(), holdable: false }, ]; this.holdDelayMs = 180; this.holdRepeatMs = 70; @@ -172,15 +172,15 @@ class Tetris { getFallbackLabel(iconName) { switch (iconName) { - case 'ic_arrow_left.png': + case 'icons/arrow_left.png': return 'L'; - case 'ic_arrow_right.png': + case 'icons/arrow_right.png': return 'R'; - case 'ic_rotate_left.png': + case 'icons/rotate_left.png': return 'RL'; - case 'ic_rotate_right.png': + case 'icons/rotate_right.png': return 'RR'; - case 'ic_home.png': + case 'icons/home.png': return 'H'; default: return '?'; From 0e098e0b288f8676e349624c6f8d74cf78c0f6eb Mon Sep 17 00:00:00 2001 From: Viet281101 Date: Tue, 24 Feb 2026 09:12:28 +0700 Subject: [PATCH 3/3] add Readme --- assets/fonts/README.md | 87 +++++++++++++++++++++++++++++++++++ assets/fonts/requirements.txt | 6 +++ 2 files changed, 93 insertions(+) create mode 100644 assets/fonts/README.md create mode 100644 assets/fonts/requirements.txt diff --git a/assets/fonts/README.md b/assets/fonts/README.md new file mode 100644 index 0000000..aee31a1 --- /dev/null +++ b/assets/fonts/README.md @@ -0,0 +1,87 @@ +# Fonts Workflow (Vietnamese Glyphs) + +This folder contains the source font and scripts to check/generate full Vietnamese glyph coverage for the project. + +## Folder Structure + +- `Pixellari.ttf`: the base font currently used in the game. +- `generate_vietnamese_full.py`: main script to create/overwrite missing Vietnamese glyphs. +- `check_vietnamese_missing.py`: quick check for missing Vietnamese glyphs in the font. +- `check_glyphs.py`: prints all glyph names available in the font. +- `requirements.txt`: minimal dependencies for Python scripts related to font processing. + +## Environment Requirements + +The scripts use **FontForge Python bindings** (`fontforge`, `psMat`). + +1. Install system dependencies (recommended on Ubuntu/Debian): + +```bash +sudo apt update +sudo apt install fontforge python3-fontforge +``` + +2. (Optional) create a virtual environment if you want isolated Python package management. + +## How to Run + +Run commands from the `assets/fonts` folder. + +### 1) Check missing glyphs before generating + +```bash +python3 check_vietnamese_missing.py +``` + +### 2) Generate full Vietnamese glyph coverage + +```bash +python3 generate_vietnamese_full.py +``` + +Default output: `Pixellari-Vietnamese.ttf`. + +Useful options: + +```bash +python3 generate_vietnamese_full.py --input Pixellari.ttf --output Pixellari-Vietnamese.ttf +python3 generate_vietnamese_full.py --force +fontforge -script generate_vietnamese_full.py --input Pixellari.ttf --output Pixellari-Vietnamese.ttf +``` + +- `--force`: overwrite Vietnamese glyphs even if they already exist (useful when regenerating from a previously generated font). + +### 3) Check again after generating + +```bash +python3 check_vietnamese_missing.py +``` + +If generation is fully successful, the final number of missing glyphs should be `0`. + +## Glyph Composition Logic (Summary) + +`generate_vietnamese_full.py` builds glyphs by **composing** from existing glyph parts in the font: + +- Decomposes Vietnamese characters with NFD to identify: + - base letter (`a`, `e`, `o`, `u`, `i`, ...) + - shape marks (`breve`, `circumflex`, `horn`) + - tone marks (`grave`, `acute`, `hook`, `tilde`, `dot below`) +- Builds the shaped base first (for example `ă`, `â`, `ê`, `ô`, `ơ`, `ư`), then applies tone marks. +- For `ơ/ư`, the script constructs the horn automatically if the base font does not provide those glyphs. +- For lowercase `i` with top marks, the script uses `dotlessi` to avoid dot overlap. +- Marks are aligned by bounding boxes and Pixellari's pixel grid, then rounded and overlap-cleaned. + +## Adding New Glyph Sets Beyond Vietnamese + +1. Open `generate_vietnamese_full.py`. +2. Update the target character set (`VIETNAMESE_REQUIRED`) or add a new constant for another language. +3. If new marks or placement rules are needed: + - add/update rules in `parse_decomposition`, `shape_base_char`, or `ensure_composed_vietnamese_glyph`. + - reuse existing helpers (`add_layer_with_alignment`, `add_hook_above`, `add_dot_below`, ...). +4. Regenerate the font and re-run the missing glyph check. + +## Notes + +- `requirements.txt` only documents minimal Python-level dependencies. In practice, `fontforge` is usually installed via the OS package manager. +- Avoid committing generated font output files for local experiments; commit only after validating rendering in the game. diff --git a/assets/fonts/requirements.txt b/assets/fonts/requirements.txt new file mode 100644 index 0000000..036a614 --- /dev/null +++ b/assets/fonts/requirements.txt @@ -0,0 +1,6 @@ +# Python dependencies for scripts in this folder. +# NOTE: `fontforge` is usually installed via OS package manager (not reliable via pip). +# Ubuntu/Debian example: +# sudo apt update && sudo apt install fontforge python3-fontforge + +fontforge