From 06b2c4d950fed1eb9ae478c016ad386e16f003df Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 26 Dec 2025 17:18:08 +0000 Subject: [PATCH 1/4] Initial plan From fb9b49942a627220bf634001afd98356c45481d8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 26 Dec 2025 17:26:13 +0000 Subject: [PATCH 2/4] Implement diagonal block merging optimization for SVG generation Co-authored-by: funaydmc <105151651+funaydmc@users.noreply.github.com> --- src/core/bitmap-processor.js | 269 +++++++++++++++++++++++++++++------ 1 file changed, 222 insertions(+), 47 deletions(-) diff --git a/src/core/bitmap-processor.js b/src/core/bitmap-processor.js index fa279e6..02b9312 100644 --- a/src/core/bitmap-processor.js +++ b/src/core/bitmap-processor.js @@ -100,6 +100,7 @@ class BitmapProcessor { /** * Convert a 2D bitmap array into an SVG path string. + * Optimized to merge diagonally adjacent blocks. * @param {number[][]} bitmap - The 2D bitmap array. * @returns {string} The SVG path data. */ @@ -107,72 +108,246 @@ class BitmapProcessor { if (!bitmap || bitmap.length === 0 || !bitmap[0]) return ''; const rows = bitmap.length; const cols = bitmap[0].length; - const edges = []; - // 1. Identify Edges - for (let r = 0; r < rows; r++) { - for (let c = 0; c < cols; c++) { - if (bitmap[r][c] === 1) { - if (r === 0 || bitmap[r - 1][c] === 0) edges.push([[c, r], [c + 1, r]]); // Top - if (c === cols - 1 || bitmap[r][c + 1] === 0) edges.push([[c + 1, r], [c + 1, r + 1]]); // Right - if (r === rows - 1 || bitmap[r + 1][c] === 0) edges.push([[c + 1, r + 1], [c, r + 1]]); // Bottom - if (c === 0 || bitmap[r][c - 1] === 0) edges.push([[c, r + 1], [c, r]]); // Left - } - } - } + // 1. Find connected components with diagonal connectivity + const components = this.findDiagonalConnectedComponents(bitmap); const pathParts = []; - const visited = new Set(); - - // 2. Connect Edges - while (visited.size < edges.length) { - let startEdgeIdx = -1; - for (let i = 0; i < edges.length; i++) { - if (!visited.has(i)) { - startEdgeIdx = i; - break; + + // 2. Process each connected component + for (const component of components) { + // Create a set of component pixels for fast lookup + const componentSet = new Set(component.map(([r, c]) => `${r},${c}`)); + const edges = []; + + // Identify edges for this component, filtering out internal edges + for (const [r, c] of component) { + // Top edge + if (r === 0 || bitmap[r - 1][c] === 0) { + const edge = [[c, r], [c + 1, r]]; + if (!this.isInternalEdge(edge, componentSet, bitmap)) { + edges.push(edge); + } + } + // Right edge + if (c === cols - 1 || bitmap[r][c + 1] === 0) { + const edge = [[c + 1, r], [c + 1, r + 1]]; + if (!this.isInternalEdge(edge, componentSet, bitmap)) { + edges.push(edge); + } + } + // Bottom edge + if (r === rows - 1 || bitmap[r + 1][c] === 0) { + const edge = [[c + 1, r + 1], [c, r + 1]]; + if (!this.isInternalEdge(edge, componentSet, bitmap)) { + edges.push(edge); + } + } + // Left edge + if (c === 0 || bitmap[r][c - 1] === 0) { + const edge = [[c, r + 1], [c, r]]; + if (!this.isInternalEdge(edge, componentSet, bitmap)) { + edges.push(edge); + } } } - if (startEdgeIdx === -1) break; - let currentLoop = []; - let [currPoint, nextPoint] = edges[startEdgeIdx]; + const visited = new Set(); - currentLoop.push(currPoint); - visited.add(startEdgeIdx); + // 3. Connect Edges into loops using right-hand rule + while (visited.size < edges.length) { + let startEdgeIdx = -1; + for (let i = 0; i < edges.length; i++) { + if (!visited.has(i)) { + startEdgeIdx = i; + break; + } + } + if (startEdgeIdx === -1) break; + + let currentLoop = []; + let [currPoint, nextPoint] = edges[startEdgeIdx]; + + currentLoop.push(currPoint); + visited.add(startEdgeIdx); + let prevEdgeIdx = startEdgeIdx; + + while (true) { + if (nextPoint[0] === currentLoop[0][0] && nextPoint[1] === currentLoop[0][1]) { + break; + } - while (true) { - if (nextPoint[0] === currentLoop[0][0] && nextPoint[1] === currentLoop[0][1]) { - break; + currentLoop.push(nextPoint); + + // Find the next edge using rightmost turn strategy + let nextEdgeIdx = this.findNextEdgeRightmost(edges, visited, prevEdgeIdx, nextPoint); + + if (nextEdgeIdx === -1) break; + + visited.add(nextEdgeIdx); + prevEdgeIdx = nextEdgeIdx; + nextPoint = edges[nextEdgeIdx][1]; } - currentLoop.push(nextPoint); - let foundNext = false; + // 4. Simplify Path + const optimizedLoop = this.simplifyPath(currentLoop); - for (let i = 0; i < edges.length; i++) { - if (!visited.has(i)) { - if (edges[i][0][0] === nextPoint[0] && edges[i][0][1] === nextPoint[1]) { - visited.add(i); - nextPoint = edges[i][1]; - foundNext = true; - break; - } + if (optimizedLoop.length > 2) { + const d = `M${optimizedLoop[0][0]} ${optimizedLoop[0][1]} ` + + optimizedLoop.slice(1).map(p => `L${p[0]} ${p[1]}`).join(' ') + 'Z'; + pathParts.push(d); + } + } + } + + return pathParts.join(' '); + } + + /** + * Find connected components considering diagonal connectivity. + * @param {number[][]} bitmap - The 2D bitmap array. + * @returns {Array>} Array of components, each containing pixel coordinates. + */ + static findDiagonalConnectedComponents(bitmap) { + if (!bitmap || bitmap.length === 0) return []; + const rows = bitmap.length; + const cols = bitmap[0].length; + const visited = Array(rows).fill(null).map(() => Array(cols).fill(false)); + const components = []; + + const dfs = (r, c, component) => { + if (r < 0 || r >= rows || c < 0 || c >= cols) return; + if (visited[r][c] || bitmap[r][c] !== 1) return; + + visited[r][c] = true; + component.push([r, c]); + + // Visit all 8 neighbors (including diagonals) + dfs(r - 1, c, component); // Top + dfs(r + 1, c, component); // Bottom + dfs(r, c - 1, component); // Left + dfs(r, c + 1, component); // Right + dfs(r - 1, c - 1, component); // Top-left + dfs(r - 1, c + 1, component); // Top-right + dfs(r + 1, c - 1, component); // Bottom-left + dfs(r + 1, c + 1, component); // Bottom-right + }; + + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + if (bitmap[r][c] === 1 && !visited[r][c]) { + const component = []; + dfs(r, c, component); + if (component.length > 0) { + components.push(component); } } - if (!foundNext) break; } + } - // 3. Simplify Path - const optimizedLoop = this.simplifyPath(currentLoop); + return components; + } - if (optimizedLoop.length > 2) { - const d = `M${optimizedLoop[0][0]} ${optimizedLoop[0][1]} ` + - optimizedLoop.slice(1).map(p => `L${p[0]} ${p[1]}`).join(' ') + 'Z'; - pathParts.push(d); + /** + * Find the next edge that makes the rightmost turn from the current edge. + * This ensures we trace the outer boundary of the shape. + * @param {Array>>} edges - All edges. + * @param {Set} visited - Set of visited edge indices. + * @param {number} prevEdgeIdx - Index of the previous edge. + * @param {Array} point - The current point [x, y]. + * @returns {number} Index of the next edge, or -1 if none found. + */ + static findNextEdgeRightmost(edges, visited, prevEdgeIdx, point) { + const prevEdge = edges[prevEdgeIdx]; + const incomingDir = [ + prevEdge[1][0] - prevEdge[0][0], + prevEdge[1][1] - prevEdge[0][1] + ]; + + let bestEdgeIdx = -1; + let bestAngle = -Infinity; + + for (let i = 0; i < edges.length; i++) { + if (visited.has(i)) continue; + if (edges[i][0][0] !== point[0] || edges[i][0][1] !== point[1]) continue; + + const outgoingDir = [ + edges[i][1][0] - edges[i][0][0], + edges[i][1][1] - edges[i][0][1] + ]; + + // Calculate the angle between incoming and outgoing directions + // We want the rightmost turn, which is the smallest clockwise angle + const angle = this.calculateTurnAngle(incomingDir, outgoingDir); + + if (angle > bestAngle) { + bestAngle = angle; + bestEdgeIdx = i; } } - return pathParts.join(' '); + return bestEdgeIdx; + } + + /** + * Calculate the turn angle from incoming to outgoing direction. + * Returns a value where larger = more clockwise turn. + * @param {Array} incoming - Incoming direction vector [dx, dy]. + * @param {Array} outgoing - Outgoing direction vector [dx, dy]. + * @returns {number} Turn angle indicator. + */ + static calculateTurnAngle(incoming, outgoing) { + // Cross product gives us the turn direction + // Positive = counterclockwise, negative = clockwise + const cross = incoming[0] * outgoing[1] - incoming[1] * outgoing[0]; + + // Dot product helps determine if it's a sharp or gentle turn + const dot = incoming[0] * outgoing[0] + incoming[1] * outgoing[1]; + + // Use atan2 for a proper angle measure + // Negate because we want clockwise (right turn) to be positive + return Math.atan2(-cross, dot); + } + + /** + * Check if an edge is internal (between two pixels in the same component). + * An edge is internal if pixels on both sides (considering diagonals) are in the same component. + * @param {Array>} edge - The edge as [[x1, y1], [x2, y2]]. + * @param {Set} componentSet - Set of pixel coordinates in the component. + * @param {number[][]} bitmap - The bitmap array. + * @returns {boolean} True if the edge is internal. + */ + static isInternalEdge(edge, componentSet, bitmap) { + const [[x1, y1], [x2, y2]] = edge; + + // Determine edge orientation and get adjacent pixels + if (x1 === x2) { + // Vertical edge: check left and right pixels + const x = x1; + const y = Math.min(y1, y2); + const leftPixel = `${y},${x - 1}`; + const rightPixel = `${y},${x}`; + + // Check if both adjacent pixels are in the component + const leftInComponent = componentSet.has(leftPixel); + const rightInComponent = componentSet.has(rightPixel); + + // Edge is internal if both sides are in the component + return leftInComponent && rightInComponent; + } else { + // Horizontal edge: check top and bottom pixels + const y = y1; + const x = Math.min(x1, x2); + const topPixel = `${y - 1},${x}`; + const bottomPixel = `${y},${x}`; + + // Check if both adjacent pixels are in the component + const topInComponent = componentSet.has(topPixel); + const bottomInComponent = componentSet.has(bottomPixel); + + // Edge is internal if both sides are in the component + return topInComponent && bottomInComponent; + } } /** From af94b9ed970026575050eee01b05aa64141ce335 Mon Sep 17 00:00:00 2001 From: Funayd <105151651+funaydmc@users.noreply.github.com> Date: Sat, 27 Dec 2025 11:57:06 +0700 Subject: [PATCH 3/4] fix --- src/build.js | 10 ++++------ src/core/font-loader.js | 10 ++++++++-- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/build.js b/src/build.js index 3378c5b..1cef685 100644 --- a/src/build.js +++ b/src/build.js @@ -77,12 +77,10 @@ async function main() { charDataList.length = CONFIG.limit; } - // 2. Build Variants in Parallel - logger.info('Starting parallel builds...'); - await Promise.all([ - buildVariant(charDataList, false), - buildVariant(charDataList, true) - ]); + // 2. Build Variants Sequentially (Parallel execution causes issues with WASM modules) + logger.info('Starting builds...'); + await buildVariant(charDataList, false); + await buildVariant(charDataList, true); logger.info('--- BUILD COMPLETE ---'); diff --git a/src/core/font-loader.js b/src/core/font-loader.js index 65369a5..e03d644 100644 --- a/src/core/font-loader.js +++ b/src/core/font-loader.js @@ -31,8 +31,14 @@ class FontLoader { try { await this.processReference('minecraft:default'); - // Sort by Unicode - this.charList.sort((a, b) => a.unicode.localeCompare(b.unicode)); + // Sort by Unicode Numeric Value (CodePoint) to ensure deterministic order limit + this.charList.sort((a, b) => (a.unicode.codePointAt(0) || 0) - (b.unicode.codePointAt(0) || 0)); + + logger.info(`Total loaded characters: ${this.charList.length}`); + logger.info(`Configuration Limit: ${CONFIG.limit}`); + if (this.charList.length > CONFIG.limit) { + logger.warn(`Will be sliced to ${CONFIG.limit}. Dropping ${this.charList.length - CONFIG.limit} characters.`); + } logger.success(`Loaded ${this.charList.length} characters.`); return this.charList; From dca1ca97df950dabf1194e925641ec0c545428bb Mon Sep 17 00:00:00 2001 From: Funayd <105151651+funaydmc@users.noreply.github.com> Date: Sat, 27 Dec 2025 12:14:15 +0700 Subject: [PATCH 4/4] Parallelize SVG generation and refactor build process --- src/build.js | 59 ++++++++++++++++++++++++++------------- src/core/svg-generator.js | 6 ++-- 2 files changed, 43 insertions(+), 22 deletions(-) diff --git a/src/build.js b/src/build.js index 1cef685..2cfc9ab 100644 --- a/src/build.js +++ b/src/build.js @@ -16,30 +16,42 @@ const FontBuilder = require('./core/font-builder'); const Subsetter = require('./core/subsetter'); /** - * Build a single font variant (Regular or Bold). - * @param {BitFont.CharData[]} charDataList - * @param {boolean} isBold + * Generate SVG content for a variant. + * Safe to run in parallel as it's pure JS CPU work. */ -async function buildVariant(charDataList, isBold) { +async function generateSvgTask(charDataList, isBold) { + const variantName = isBold ? 'BOLD' : 'REGULAR'; + logger.info(`[${variantName}] Generating SVG path data...`); + const start = Date.now(); + + // CPU intensive task + const svgContent = SvgGenerator.generate(charDataList, isBold); + + logger.info(`[${variantName}] SVG generated in ${(Date.now() - start) / 1000}s`); + return { svgContent, isBold }; +} + +/** + * Build font from SVG and create subsets. + * MUST run sequentially due to WASM constraints in fonteditor-core. + */ +async function buildFontTask({ svgContent, isBold }) { const variantName = isBold ? 'BOLD' : 'REGULAR'; const fileName = isBold ? 'MinecraftFont-Bold.woff2' : 'MinecraftFont.woff2'; const outputPath = path.join(CONFIG.paths.dist, fileName); const tempTtfPath = outputPath.replace('.woff2', '.ttf'); - logger.info(`[${variantName}] Generating font data...`); - - // 1. Generate SVG - const svgContent = SvgGenerator.generate(charDataList, isBold); + logger.info(`[${variantName}] Building font file (WASM)...`); - // 2. Create Font Object + // 1. Create Font Object + // This parses the SVG string. It's heavy but must be sequential. const fontObj = FontBuilder.createFont(svgContent); FontBuilder.setMetadata(fontObj, isBold); - // 3. Write WOFF2 + // 2. Write WOFF2 FontBuilder.writeWOFF2(fontObj, outputPath); - // 4. Create Subsets (Requires TTF) - // We generate a temp TTF, create subsets, then delete it. + // 3. Create Subsets (Requires TTF) FontBuilder.writeTTF(fontObj, tempTtfPath); try { @@ -54,6 +66,7 @@ async function buildVariant(charDataList, isBold) { async function main() { logger.info('--- STARTING BUILD PROCESS ---'); + const totalStart = Date.now(); // Ensure output directory exists if (!fs.existsSync(CONFIG.paths.dist)) { @@ -73,16 +86,24 @@ async function main() { // Apply Limits if configured if (CONFIG.limit && charDataList.length > CONFIG.limit) { logger.warn(`Limit applied: ${CONFIG.limit} chars.`); - // Note: Array is already sorted by FontLoader charDataList.length = CONFIG.limit; } - // 2. Build Variants Sequentially (Parallel execution causes issues with WASM modules) - logger.info('Starting builds...'); - await buildVariant(charDataList, false); - await buildVariant(charDataList, true); + // 2. Generate SVGs in PARALLEL (CPU bound) + logger.info('Starting parallel SVG generation...'); + const svgResults = await Promise.all([ + generateSvgTask(charDataList, false), + generateSvgTask(charDataList, true) + ]); + + // 3. Build Fonts SEQUENTIALLY (WASM bound) + logger.info('Starting sequential font building...'); + for (const result of svgResults) { + await buildFontTask(result); + } - logger.info('--- BUILD COMPLETE ---'); + const duration = (Date.now() - totalStart) / 1000; + logger.info(`--- BUILD COMPLETE in ${duration}s ---`); } catch (error) { logger.error('Build failed:', error); @@ -90,4 +111,4 @@ async function main() { } } -main(); +main(); \ No newline at end of file diff --git a/src/core/svg-generator.js b/src/core/svg-generator.js index 0602316..e1b63d4 100644 --- a/src/core/svg-generator.js +++ b/src/core/svg-generator.js @@ -14,7 +14,7 @@ class SvgGenerator { * @returns {string} The complete SVG string. */ static generate(charDataList, isBold = false) { - let glyphsXML = ''; + const glyphsXML = []; const usedCodePoints = new Set(); for (const charData of charDataList) { @@ -22,10 +22,10 @@ class SvgGenerator { if (!codePoint || usedCodePoints.has(codePoint)) continue; usedCodePoints.add(codePoint); - glyphsXML += this.createGlyphXML(charData, isBold); + glyphsXML.push(this.createGlyphXML(charData, isBold)); } - return this.createFontXML(glyphsXML, isBold); + return this.createFontXML(glyphsXML.join(''), isBold); } /**