honeytree 1.1.6 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +43 -141
- package/bin/honeydew.js +4 -3
- package/package.json +9 -9
- package/src/animation-keyframes.js +890 -0
- package/src/animation.js +151 -0
- package/src/camera.js +54 -0
- package/src/diffactions.js +32 -0
- package/src/diffpanel.js +83 -0
- package/src/diffparser.js +44 -0
- package/src/diffwatch.js +52 -0
- package/src/pointcloud.js +193 -0
- package/src/renderer.js +49 -3
- package/src/renderer3d.js +135 -0
- package/src/scanner.js +102 -0
- package/src/sprites.js +2 -1
- package/src/viewer.js +375 -148
package/src/animation.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { ANIMATION_KEYFRAMES } from "./animation-keyframes.js";
|
|
2
|
+
import { getSprite } from "./sprites.js";
|
|
3
|
+
|
|
4
|
+
const CHAR_ORDER = [" ", "░", "▒", "█"];
|
|
5
|
+
|
|
6
|
+
function charIndex(ch) {
|
|
7
|
+
const idx = CHAR_ORDER.indexOf(ch);
|
|
8
|
+
return idx >= 0 ? idx : 0;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function interpolateChar(ch1, ch2, t) {
|
|
12
|
+
const i1 = charIndex(ch1);
|
|
13
|
+
const i2 = charIndex(ch2);
|
|
14
|
+
const idx = Math.round(i1 + (i2 - i1) * t);
|
|
15
|
+
return CHAR_ORDER[Math.max(0, Math.min(CHAR_ORDER.length - 1, idx))];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function parseHex(hex) {
|
|
19
|
+
const h = hex.startsWith("#") ? hex.slice(1) : hex;
|
|
20
|
+
return {
|
|
21
|
+
r: parseInt(h.slice(0, 2), 16),
|
|
22
|
+
g: parseInt(h.slice(2, 4), 16),
|
|
23
|
+
b: parseInt(h.slice(4, 6), 16),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function toHex({ r, g, b }) {
|
|
28
|
+
const c = (v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, "0");
|
|
29
|
+
return `#${c(r)}${c(g)}${c(b)}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function lerpColor(hex1, hex2, t) {
|
|
33
|
+
const c1 = parseHex(hex1);
|
|
34
|
+
const c2 = parseHex(hex2);
|
|
35
|
+
return toHex({
|
|
36
|
+
r: c1.r + (c2.r - c1.r) * t,
|
|
37
|
+
g: c1.g + (c2.g - c1.g) * t,
|
|
38
|
+
b: c1.b + (c2.b - c1.b) * t,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function interpolateSprite(sprite1, sprite2, t) {
|
|
43
|
+
const rows = sprite1.rows.map((row, r) =>
|
|
44
|
+
row.map(([ch1, col1], c) => {
|
|
45
|
+
const [ch2, col2] = sprite2.rows[r][c];
|
|
46
|
+
if (!col1 && !col2) return [" ", null];
|
|
47
|
+
if (!col1 && col2) {
|
|
48
|
+
const ch = interpolateChar(" ", ch2, t);
|
|
49
|
+
if (ch === " ") return [" ", null];
|
|
50
|
+
return [ch, col2];
|
|
51
|
+
}
|
|
52
|
+
if (col1 && !col2) {
|
|
53
|
+
const ch = interpolateChar(ch1, " ", t);
|
|
54
|
+
if (ch === " ") return [" ", null];
|
|
55
|
+
return [ch, col1];
|
|
56
|
+
}
|
|
57
|
+
const ch = interpolateChar(ch1, ch2, t);
|
|
58
|
+
const col = lerpColor(col1, col2, t);
|
|
59
|
+
return [ch, col];
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
return { rows, width: sprite1.width };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function brightenSprite(sprite, amount) {
|
|
66
|
+
const rows = sprite.rows.map((row) =>
|
|
67
|
+
row.map(([ch, col]) => {
|
|
68
|
+
if (!col) return [ch, col];
|
|
69
|
+
const c = parseHex(col);
|
|
70
|
+
return [ch, toHex({
|
|
71
|
+
r: Math.min(255, c.r + (255 - c.r) * amount),
|
|
72
|
+
g: Math.min(255, c.g + (255 - c.g) * amount),
|
|
73
|
+
b: Math.min(255, c.b + (255 - c.b) * amount),
|
|
74
|
+
})];
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
return { rows, width: sprite.width };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function getAnimationFrames(type, growth, frameCount = 40) {
|
|
81
|
+
const allKeyframes = ANIMATION_KEYFRAMES[type];
|
|
82
|
+
if (!allKeyframes) throw new Error(`No animation keyframes for type: ${type}`);
|
|
83
|
+
|
|
84
|
+
const finalSprite = getSprite(type, growth);
|
|
85
|
+
|
|
86
|
+
let keyframes;
|
|
87
|
+
if (growth >= 0.8) {
|
|
88
|
+
keyframes = allKeyframes;
|
|
89
|
+
} else {
|
|
90
|
+
let maxKF;
|
|
91
|
+
if (growth < 0.2) maxKF = 3;
|
|
92
|
+
else if (growth < 0.5) maxKF = 6;
|
|
93
|
+
else maxKF = 8;
|
|
94
|
+
|
|
95
|
+
keyframes = allKeyframes.slice(0, maxKF);
|
|
96
|
+
const lastTime = keyframes[keyframes.length - 1].time;
|
|
97
|
+
const timeScale = 4.0 / Math.max(lastTime, 0.1);
|
|
98
|
+
|
|
99
|
+
keyframes = keyframes.map((kf) => ({ ...kf, time: kf.time * timeScale }));
|
|
100
|
+
keyframes.push({ time: 4.0, sprite: finalSprite });
|
|
101
|
+
keyframes.push({ time: 4.5, sprite: brightenSprite(finalSprite, 0.2) });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const totalDuration = 5.0;
|
|
105
|
+
const frames = [];
|
|
106
|
+
|
|
107
|
+
for (let i = 0; i < frameCount; i++) {
|
|
108
|
+
const t = (i / (frameCount - 1)) * totalDuration;
|
|
109
|
+
|
|
110
|
+
let kfBefore = keyframes[0];
|
|
111
|
+
let kfAfter = keyframes[0];
|
|
112
|
+
|
|
113
|
+
for (let k = 0; k < keyframes.length - 1; k++) {
|
|
114
|
+
if (t >= keyframes[k].time && t <= keyframes[k + 1].time) {
|
|
115
|
+
kfBefore = keyframes[k];
|
|
116
|
+
kfAfter = keyframes[k + 1];
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
if (k === keyframes.length - 2) {
|
|
120
|
+
kfBefore = keyframes[keyframes.length - 1];
|
|
121
|
+
kfAfter = keyframes[keyframes.length - 1];
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const segmentDuration = kfAfter.time - kfBefore.time;
|
|
126
|
+
const segmentT = segmentDuration > 0 ? (t - kfBefore.time) / segmentDuration : 1;
|
|
127
|
+
const clampedT = Math.max(0, Math.min(1, segmentT));
|
|
128
|
+
|
|
129
|
+
const sprite = interpolateSprite(kfBefore.sprite, kfAfter.sprite, clampedT);
|
|
130
|
+
|
|
131
|
+
let groundOverlay = null;
|
|
132
|
+
if (t < 0.5 && kfBefore.groundEffect) {
|
|
133
|
+
const ge = kfBefore.groundEffect;
|
|
134
|
+
groundOverlay = [];
|
|
135
|
+
for (let dx = -ge.radius; dx <= ge.radius; dx++) {
|
|
136
|
+
const intensity = 1 - Math.abs(dx) / ge.radius;
|
|
137
|
+
if (intensity > 0.3) {
|
|
138
|
+
groundOverlay.push({ dx, char: "░", color: ge.color });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (i === frameCount - 1) {
|
|
144
|
+
frames.push({ sprite: finalSprite, groundOverlay: null, groundPulse: false });
|
|
145
|
+
} else {
|
|
146
|
+
frames.push({ sprite, groundOverlay, groundPulse: i < 3 });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return frames;
|
|
151
|
+
}
|
package/src/camera.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// src/camera.js
|
|
2
|
+
|
|
3
|
+
const DEG_TO_RAD = Math.PI / 180;
|
|
4
|
+
|
|
5
|
+
export function createCamera() {
|
|
6
|
+
return {
|
|
7
|
+
azimuth: 45,
|
|
8
|
+
elevation: 30,
|
|
9
|
+
distance: 60,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function rotatePoint(x, y, z, azimuthDeg, elevationDeg) {
|
|
14
|
+
const az = azimuthDeg * DEG_TO_RAD;
|
|
15
|
+
const el = elevationDeg * DEG_TO_RAD;
|
|
16
|
+
|
|
17
|
+
const cosAz = Math.cos(az);
|
|
18
|
+
const sinAz = Math.sin(az);
|
|
19
|
+
const x1 = x * cosAz + z * sinAz;
|
|
20
|
+
const z1 = -x * sinAz + z * cosAz;
|
|
21
|
+
|
|
22
|
+
const cosEl = Math.cos(el);
|
|
23
|
+
const sinEl = Math.sin(el);
|
|
24
|
+
const y1 = y * cosEl - z1 * sinEl;
|
|
25
|
+
const z2 = y * sinEl + z1 * cosEl;
|
|
26
|
+
|
|
27
|
+
return [x1, y1, z2];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function projectPoint(x, y, z, screenWidth, screenHeight, distance = 25) {
|
|
31
|
+
const fov = 60;
|
|
32
|
+
const fovRad = fov * DEG_TO_RAD;
|
|
33
|
+
const focalLength = screenHeight / (2 * Math.tan(fovRad / 2));
|
|
34
|
+
|
|
35
|
+
const zView = z - distance;
|
|
36
|
+
|
|
37
|
+
if (zView >= -1) {
|
|
38
|
+
return { screenX: -1, screenY: -1, depth: Infinity, visible: false };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const scale = focalLength / -zView;
|
|
42
|
+
const screenX = Math.round(x * scale * 2 + screenWidth / 2);
|
|
43
|
+
const screenY = Math.round(-y * scale + screenHeight / 2);
|
|
44
|
+
|
|
45
|
+
return { screenX, screenY, depth: -zView, visible: true };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function clampElevation(elevation) {
|
|
49
|
+
return Math.max(10, Math.min(80, elevation));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function clampAzimuth(azimuth) {
|
|
53
|
+
return ((azimuth % 360) + 360) % 360;
|
|
54
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { hunkToPatch } from "./diffparser.js";
|
|
3
|
+
|
|
4
|
+
export function stageHunk(rootDir, filePath, hunk) {
|
|
5
|
+
try {
|
|
6
|
+
const patch = hunkToPatch(filePath, hunk);
|
|
7
|
+
execSync("git apply --cached --unidiff-zero -", {
|
|
8
|
+
cwd: rootDir,
|
|
9
|
+
input: patch,
|
|
10
|
+
timeout: 5000,
|
|
11
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
12
|
+
});
|
|
13
|
+
return { ok: true };
|
|
14
|
+
} catch (err) {
|
|
15
|
+
return { ok: false, error: err.message };
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function revertHunk(rootDir, filePath, hunk) {
|
|
20
|
+
try {
|
|
21
|
+
const patch = hunkToPatch(filePath, hunk);
|
|
22
|
+
execSync("git apply --reverse --unidiff-zero -", {
|
|
23
|
+
cwd: rootDir,
|
|
24
|
+
input: patch,
|
|
25
|
+
timeout: 5000,
|
|
26
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
27
|
+
});
|
|
28
|
+
return { ok: true };
|
|
29
|
+
} catch (err) {
|
|
30
|
+
return { ok: false, error: err.message };
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/diffpanel.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// src/diffpanel.js
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
|
|
4
|
+
chalk.level = 3;
|
|
5
|
+
|
|
6
|
+
export function createDiffPanel(filePath, hunks) {
|
|
7
|
+
return {
|
|
8
|
+
filePath,
|
|
9
|
+
hunks,
|
|
10
|
+
currentHunk: 0,
|
|
11
|
+
hunkStatus: hunks.map(() => "pending"),
|
|
12
|
+
scrollOffset: 0,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function renderDiffPanel(panel, width, height) {
|
|
17
|
+
const lines = [];
|
|
18
|
+
const totalAdded = panel.hunks.reduce((s, h) => s + h.added, 0);
|
|
19
|
+
const totalRemoved = panel.hunks.reduce((s, h) => s + h.removed, 0);
|
|
20
|
+
|
|
21
|
+
// Header
|
|
22
|
+
const header = ` ${panel.filePath} +${totalAdded} -${totalRemoved} ${panel.hunks.length} hunks`;
|
|
23
|
+
lines.push(chalk.hex("#f5a50b").bold(header.padEnd(width).slice(0, width)));
|
|
24
|
+
|
|
25
|
+
// Separator
|
|
26
|
+
lines.push(chalk.hex("#555555")("─".repeat(width)));
|
|
27
|
+
|
|
28
|
+
// Help line
|
|
29
|
+
const help = " j/k: navigate a: accept r: reject A/R: all Esc: close";
|
|
30
|
+
lines.push(chalk.hex("#888888")(help.padEnd(width).slice(0, width)));
|
|
31
|
+
|
|
32
|
+
lines.push(chalk.hex("#555555")("─".repeat(width)));
|
|
33
|
+
|
|
34
|
+
// Render hunks
|
|
35
|
+
const bodyHeight = height - lines.length;
|
|
36
|
+
const bodyLines = [];
|
|
37
|
+
|
|
38
|
+
for (let hi = 0; hi < panel.hunks.length; hi++) {
|
|
39
|
+
const hunk = panel.hunks[hi];
|
|
40
|
+
const isCurrent = hi === panel.currentHunk;
|
|
41
|
+
const status = panel.hunkStatus[hi];
|
|
42
|
+
|
|
43
|
+
// Hunk header with status
|
|
44
|
+
let statusIcon = "[ ]";
|
|
45
|
+
if (status === "accepted") statusIcon = chalk.green("[✓]");
|
|
46
|
+
else if (status === "rejected") statusIcon = chalk.red("[✗]");
|
|
47
|
+
|
|
48
|
+
const hunkHeader = isCurrent
|
|
49
|
+
? chalk.hex("#ffcc44").bold(`▸ Hunk ${hi + 1}/${panel.hunks.length} ${statusIcon} ${hunk.header}`)
|
|
50
|
+
: chalk.hex("#888888")(` Hunk ${hi + 1}/${panel.hunks.length} ${statusIcon} ${hunk.header}`);
|
|
51
|
+
|
|
52
|
+
bodyLines.push(hunkHeader.padEnd(width).slice(0, width));
|
|
53
|
+
|
|
54
|
+
// Hunk lines
|
|
55
|
+
for (const line of hunk.lines) {
|
|
56
|
+
let rendered;
|
|
57
|
+
const text = line.text.padEnd(width).slice(0, width);
|
|
58
|
+
if (line.type === "added") {
|
|
59
|
+
rendered = chalk.green(text);
|
|
60
|
+
} else if (line.type === "removed") {
|
|
61
|
+
rendered = chalk.red(text);
|
|
62
|
+
} else {
|
|
63
|
+
rendered = isCurrent ? chalk.hex("#cccccc")(text) : chalk.hex("#666666")(text);
|
|
64
|
+
}
|
|
65
|
+
bodyLines.push(rendered);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
bodyLines.push(""); // blank line between hunks
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Apply scroll offset and fill to height
|
|
72
|
+
const scrolled = bodyLines.slice(panel.scrollOffset, panel.scrollOffset + bodyHeight);
|
|
73
|
+
for (const line of scrolled) {
|
|
74
|
+
lines.push(line);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Pad remaining height
|
|
78
|
+
while (lines.length < height) {
|
|
79
|
+
lines.push(" ".repeat(width));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return lines.slice(0, height);
|
|
83
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export function parseDiff(diffText) {
|
|
2
|
+
if (!diffText || !diffText.trim()) return [];
|
|
3
|
+
|
|
4
|
+
const lines = diffText.split("\n");
|
|
5
|
+
const hunks = [];
|
|
6
|
+
let currentHunk = null;
|
|
7
|
+
|
|
8
|
+
for (const line of lines) {
|
|
9
|
+
if (line.startsWith("@@")) {
|
|
10
|
+
if (currentHunk) {
|
|
11
|
+
currentHunk.added = currentHunk.lines.filter(l => l.type === "added").length;
|
|
12
|
+
currentHunk.removed = currentHunk.lines.filter(l => l.type === "removed").length;
|
|
13
|
+
hunks.push(currentHunk);
|
|
14
|
+
}
|
|
15
|
+
currentHunk = { header: line, lines: [], added: 0, removed: 0 };
|
|
16
|
+
} else if (currentHunk) {
|
|
17
|
+
if (line.startsWith("+")) {
|
|
18
|
+
currentHunk.lines.push({ type: "added", text: line });
|
|
19
|
+
} else if (line.startsWith("-")) {
|
|
20
|
+
currentHunk.lines.push({ type: "removed", text: line });
|
|
21
|
+
} else if (line.startsWith(" ") || line === "") {
|
|
22
|
+
currentHunk.lines.push({ type: "context", text: line });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (currentHunk) {
|
|
28
|
+
currentHunk.added = currentHunk.lines.filter(l => l.type === "added").length;
|
|
29
|
+
currentHunk.removed = currentHunk.lines.filter(l => l.type === "removed").length;
|
|
30
|
+
hunks.push(currentHunk);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return hunks;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function hunkToPatch(filePath, hunk) {
|
|
37
|
+
const lines = [
|
|
38
|
+
`--- a/${filePath}`,
|
|
39
|
+
`+++ b/${filePath}`,
|
|
40
|
+
hunk.header,
|
|
41
|
+
...hunk.lines.map(l => l.text),
|
|
42
|
+
];
|
|
43
|
+
return lines.join("\n") + "\n";
|
|
44
|
+
}
|
package/src/diffwatch.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
function execAsync(cmd, args, opts) {
|
|
6
|
+
return new Promise((resolve) => {
|
|
7
|
+
execFile(cmd, args, { timeout: 5000, encoding: "utf-8", ...opts }, (err, stdout) => {
|
|
8
|
+
resolve(err ? "" : stdout);
|
|
9
|
+
});
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function getChangedFiles(rootDir) {
|
|
14
|
+
const changed = new Set();
|
|
15
|
+
|
|
16
|
+
const [tracked, untracked] = await Promise.all([
|
|
17
|
+
execAsync("git", ["diff", "--name-only"], { cwd: rootDir }),
|
|
18
|
+
execAsync("git", ["ls-files", "--others", "--exclude-standard"], { cwd: rootDir }),
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
for (const output of [tracked, untracked]) {
|
|
22
|
+
for (const line of output.split("\n")) {
|
|
23
|
+
const trimmed = line.trim();
|
|
24
|
+
if (trimmed) changed.add(trimmed);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return changed;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function getFileDiff(rootDir, filePath) {
|
|
32
|
+
return execAsync("git", ["diff", "-U3", "--", filePath], { cwd: rootDir });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function watchForChanges(rootDir, callback) {
|
|
36
|
+
let debounceTimer = null;
|
|
37
|
+
const debounced = () => {
|
|
38
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
39
|
+
debounceTimer = setTimeout(callback, 300);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const watcher = fs.watch(rootDir, { recursive: true }, (event, filename) => {
|
|
44
|
+
if (!filename) return;
|
|
45
|
+
if (filename.includes("node_modules") || filename.includes(".git")) return;
|
|
46
|
+
debounced();
|
|
47
|
+
});
|
|
48
|
+
return watcher;
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
const SPECIES = {
|
|
2
|
+
oak: { name: "oak", colors: ["#55cc44", "#44bb33", "#66dd55", "#77ee66", "#3da832"], shape: "ellipsoid", widthScale: 1.4, heightScale: 1.0 },
|
|
3
|
+
pine: { name: "pine", colors: ["#33bbaa", "#22aa99", "#44ccbb", "#55ddcc", "#11997a"], shape: "cone", widthScale: 0.7, heightScale: 1.6 },
|
|
4
|
+
birch: { name: "birch", colors: ["#ff77cc", "#ee55bb", "#ff99dd", "#ffaaee", "#dd44aa"], shape: "ellipsoid", widthScale: 0.8, heightScale: 1.0 },
|
|
5
|
+
willow: { name: "willow", colors: ["#aadd44", "#99cc33", "#bbee55", "#ccff66", "#88bb22"], shape: "drooping", widthScale: 1.2, heightScale: 1.0 },
|
|
6
|
+
cherry: { name: "cherry", colors: ["#ff88cc", "#ff66bb", "#ffaadd", "#ffbbee", "#ee55aa", "#ff99ff", "#dd77cc"], shape: "sphere", widthScale: 1.0, heightScale: 1.0 },
|
|
7
|
+
default: { name: "default", colors: ["#88bb88", "#77aa77", "#99cc99", "#aaddaa"], shape: "ellipsoid", widthScale: 1.2, heightScale: 1.0 },
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const TRUNK_COLOR = "#8B6914";
|
|
11
|
+
const AMBER_COLORS = ["#ff3333", "#ff5555", "#ff1111", "#ff4444", "#ffffff", "#ffcccc"];
|
|
12
|
+
|
|
13
|
+
const EXT_MAP = {
|
|
14
|
+
".js": "oak", ".jsx": "oak", ".mjs": "oak", ".cjs": "oak",
|
|
15
|
+
".ts": "pine", ".tsx": "pine", ".mts": "pine",
|
|
16
|
+
".css": "birch", ".scss": "birch", ".sass": "birch", ".less": "birch",
|
|
17
|
+
".py": "willow", ".pyw": "willow",
|
|
18
|
+
".md": "cherry", ".json": "cherry", ".yaml": "cherry", ".yml": "cherry",
|
|
19
|
+
".toml": "cherry", ".xml": "cherry", ".ini": "cherry",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function getSpecies(extension) {
|
|
23
|
+
const key = EXT_MAP[extension] || "default";
|
|
24
|
+
return SPECIES[key];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function seededRandom(seed) {
|
|
28
|
+
let s = seed >>> 0;
|
|
29
|
+
return () => {
|
|
30
|
+
s = Math.imul((s >>> 16) ^ s, 0x45d9f3b) >>> 0;
|
|
31
|
+
s = Math.imul((s >>> 16) ^ s, 0x45d9f3b) >>> 0;
|
|
32
|
+
s = ((s >>> 16) ^ s) >>> 0;
|
|
33
|
+
return s / 0x100000000;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function hashString(str) {
|
|
38
|
+
let h = 0;
|
|
39
|
+
for (let i = 0; i < str.length; i++) {
|
|
40
|
+
h = Math.imul(31, h) + str.charCodeAt(i) | 0;
|
|
41
|
+
}
|
|
42
|
+
return h >>> 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function generateTreeCloud(file, position, fileIndex = 0, lodScale = 1) {
|
|
46
|
+
const species = getSpecies(file.extension);
|
|
47
|
+
const colors = file.changed ? AMBER_COLORS : species.colors;
|
|
48
|
+
const seed = hashString(file.relativePath);
|
|
49
|
+
const rng = seededRandom(seed);
|
|
50
|
+
|
|
51
|
+
const sizeLog = Math.log2(Math.max(1, file.size));
|
|
52
|
+
const changedBoost = file.changed ? 1.5 : 1;
|
|
53
|
+
const basePoints = Math.round((220 + sizeLog * 35) * changedBoost);
|
|
54
|
+
const churnMultiplier = 1 + Math.min(1, (file.churn || 0) / 30);
|
|
55
|
+
const canopyCount = Math.round(basePoints * churnMultiplier * lodScale);
|
|
56
|
+
|
|
57
|
+
const height = 5 + sizeLog * 1.1;
|
|
58
|
+
const canopyCenterY = height;
|
|
59
|
+
const canopyRadiusX = (height * 0.45) * species.widthScale * changedBoost;
|
|
60
|
+
const canopyRadiusY = (height * 0.5) * species.heightScale;
|
|
61
|
+
const canopyRadiusZ = canopyRadiusX;
|
|
62
|
+
|
|
63
|
+
const points = [];
|
|
64
|
+
|
|
65
|
+
for (let i = 0; i < canopyCount; i++) {
|
|
66
|
+
let px, py, pz;
|
|
67
|
+
|
|
68
|
+
if (species.shape === "cone") {
|
|
69
|
+
const t = rng();
|
|
70
|
+
const angle = rng() * Math.PI * 2;
|
|
71
|
+
const radius = t * canopyRadiusX;
|
|
72
|
+
px = Math.cos(angle) * radius;
|
|
73
|
+
pz = Math.sin(angle) * radius;
|
|
74
|
+
py = canopyCenterY + canopyRadiusY * (1 - t);
|
|
75
|
+
} else if (species.shape === "drooping") {
|
|
76
|
+
const u = rng() * Math.PI * 2;
|
|
77
|
+
const v = rng() * Math.PI;
|
|
78
|
+
const r = rng();
|
|
79
|
+
px = Math.cos(u) * Math.sin(v) * canopyRadiusX * r;
|
|
80
|
+
py = canopyCenterY + Math.cos(v) * canopyRadiusY * r;
|
|
81
|
+
pz = Math.sin(u) * Math.sin(v) * canopyRadiusZ * r;
|
|
82
|
+
if (rng() < 0.3) {
|
|
83
|
+
py = canopyCenterY - rng() * canopyRadiusY * 0.8;
|
|
84
|
+
}
|
|
85
|
+
} else if (species.shape === "sphere") {
|
|
86
|
+
const u = rng() * Math.PI * 2;
|
|
87
|
+
const v = Math.acos(2 * rng() - 1);
|
|
88
|
+
const r = Math.cbrt(rng()) * canopyRadiusX;
|
|
89
|
+
px = Math.cos(u) * Math.sin(v) * r;
|
|
90
|
+
py = canopyCenterY + Math.cos(v) * r;
|
|
91
|
+
pz = Math.sin(u) * Math.sin(v) * r;
|
|
92
|
+
} else {
|
|
93
|
+
const u = rng() * Math.PI * 2;
|
|
94
|
+
const v = Math.acos(2 * rng() - 1);
|
|
95
|
+
const r = Math.cbrt(rng());
|
|
96
|
+
px = Math.cos(u) * Math.sin(v) * canopyRadiusX * r;
|
|
97
|
+
py = canopyCenterY + Math.cos(v) * canopyRadiusY * r;
|
|
98
|
+
pz = Math.sin(u) * Math.sin(v) * canopyRadiusZ * r;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const color = colors[Math.floor(rng() * colors.length)];
|
|
102
|
+
points.push({
|
|
103
|
+
x: position.x + px,
|
|
104
|
+
y: py,
|
|
105
|
+
z: position.z + pz,
|
|
106
|
+
color,
|
|
107
|
+
fileIndex,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const trunkCount = Math.round((8 + height * 2) * Math.max(0.5, lodScale));
|
|
112
|
+
for (let i = 0; i < trunkCount; i++) {
|
|
113
|
+
const t = i / trunkCount;
|
|
114
|
+
points.push({
|
|
115
|
+
x: position.x + (rng() - 0.5) * 0.5,
|
|
116
|
+
y: t * (canopyCenterY - canopyRadiusY * 0.5),
|
|
117
|
+
z: position.z + (rng() - 0.5) * 0.5,
|
|
118
|
+
color: TRUNK_COLOR,
|
|
119
|
+
fileIndex,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return points;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function generateForestCloud(files) {
|
|
127
|
+
const dirGroups = {};
|
|
128
|
+
for (let i = 0; i < files.length; i++) {
|
|
129
|
+
const dir = files[i].directory || ".";
|
|
130
|
+
const topDir = dir === "." ? "." : dir.split("/")[0];
|
|
131
|
+
if (!dirGroups[topDir]) dirGroups[topDir] = [];
|
|
132
|
+
dirGroups[topDir].push({ file: files[i], index: i });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const dirs = Object.keys(dirGroups);
|
|
136
|
+
const totalFiles = files.length;
|
|
137
|
+
const spreadRadius = Math.max(20, Math.sqrt(totalFiles) * 8);
|
|
138
|
+
|
|
139
|
+
const MAX_POINTS = 80000;
|
|
140
|
+
const estimatedPointsPerFile = 400;
|
|
141
|
+
const estimatedTotal = totalFiles * estimatedPointsPerFile;
|
|
142
|
+
const lodScale = estimatedTotal > MAX_POINTS ? MAX_POINTS / estimatedTotal : 1;
|
|
143
|
+
|
|
144
|
+
const allPoints = [];
|
|
145
|
+
const filePaths = files.map((f) => f.relativePath);
|
|
146
|
+
|
|
147
|
+
dirs.forEach((dir, dirIndex) => {
|
|
148
|
+
const angle = (dirIndex / dirs.length) * Math.PI * 2;
|
|
149
|
+
const clusterCenterX = Math.cos(angle) * spreadRadius * 0.6;
|
|
150
|
+
const clusterCenterZ = Math.sin(angle) * spreadRadius * 0.6;
|
|
151
|
+
|
|
152
|
+
const group = dirGroups[dir];
|
|
153
|
+
const clusterSpread = Math.max(8, Math.sqrt(group.length) * 6);
|
|
154
|
+
|
|
155
|
+
group.forEach((entry, fileInGroup) => {
|
|
156
|
+
const seed = hashString(entry.file.relativePath);
|
|
157
|
+
const rng = seededRandom(seed);
|
|
158
|
+
const fx = clusterCenterX + (rng() - 0.5) * clusterSpread;
|
|
159
|
+
const fz = clusterCenterZ + (rng() - 0.5) * clusterSpread;
|
|
160
|
+
|
|
161
|
+
const treePoints = generateTreeCloud(entry.file, { x: fx, z: fz }, entry.index, lodScale);
|
|
162
|
+
allPoints.push(...treePoints);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
return { points: allPoints, filePaths };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function generateGroundPlane(radius) {
|
|
170
|
+
const points = [];
|
|
171
|
+
const step = 0.7;
|
|
172
|
+
const groundColors = ["#3a2a1a", "#4a3a2a", "#352515", "#2a1a0a"];
|
|
173
|
+
|
|
174
|
+
for (let x = -radius; x <= radius; x += step) {
|
|
175
|
+
for (let z = -radius; z <= radius; z += step) {
|
|
176
|
+
if (x * x + z * z > radius * radius) continue;
|
|
177
|
+
|
|
178
|
+
const seed = hashString(`ground_${x}_${z}`);
|
|
179
|
+
const rng = seededRandom(seed);
|
|
180
|
+
const color = groundColors[Math.floor(rng() * groundColors.length)];
|
|
181
|
+
|
|
182
|
+
points.push({
|
|
183
|
+
x,
|
|
184
|
+
y: 0,
|
|
185
|
+
z,
|
|
186
|
+
color,
|
|
187
|
+
fileIndex: -1,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return points;
|
|
193
|
+
}
|
package/src/renderer.js
CHANGED
|
@@ -160,6 +160,14 @@ function getTreeYOffset(treeId) {
|
|
|
160
160
|
return h % 2; // Returns 0 or 1 (only up, never below ground)
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
function getWindOffset(treeId, windTick) {
|
|
164
|
+
if (windTick == null) return 0;
|
|
165
|
+
const windDirection = Math.floor(windTick / 4) % 2 === 0 ? 1 : -1;
|
|
166
|
+
const treePhase = hash(treeId * 7) % 3;
|
|
167
|
+
const effectiveTick = Math.max(0, windTick - treePhase);
|
|
168
|
+
return (effectiveTick % 2 === 0) ? 0 : windDirection;
|
|
169
|
+
}
|
|
170
|
+
|
|
163
171
|
function generateStars(width, biome, twinkle = 0) {
|
|
164
172
|
const stars = [];
|
|
165
173
|
for (let x = 0; x < width; x += 1) {
|
|
@@ -175,14 +183,16 @@ function generateStars(width, biome, twinkle = 0) {
|
|
|
175
183
|
return stars;
|
|
176
184
|
}
|
|
177
185
|
|
|
178
|
-
function compositeSprite(buffer, sprite, centerX, baseY) {
|
|
186
|
+
function compositeSprite(buffer, sprite, centerX, baseY, canopyShiftX = 0) {
|
|
179
187
|
const offsetX = centerX - Math.floor(sprite.width / 2);
|
|
188
|
+
const trunkRows = 2;
|
|
180
189
|
for (let rowIndex = 0; rowIndex < sprite.rows.length; rowIndex += 1) {
|
|
181
190
|
const targetY = baseY - rowIndex;
|
|
182
191
|
if (targetY < 0 || targetY >= buffer.length) continue;
|
|
183
192
|
const row = sprite.rows[rowIndex];
|
|
193
|
+
const shiftX = rowIndex < trunkRows ? 0 : canopyShiftX;
|
|
184
194
|
for (let columnIndex = 0; columnIndex < row.length; columnIndex += 1) {
|
|
185
|
-
const targetX = offsetX + columnIndex;
|
|
195
|
+
const targetX = offsetX + columnIndex + shiftX;
|
|
186
196
|
if (targetX < 0 || targetX >= buffer[0].length) continue;
|
|
187
197
|
const [char, color] = row[columnIndex];
|
|
188
198
|
if (!color) continue;
|
|
@@ -320,10 +330,46 @@ export function renderFrame(forest, termWidth = 80, options = {}) {
|
|
|
320
330
|
}
|
|
321
331
|
}
|
|
322
332
|
|
|
333
|
+
const windTick = options.windTick ?? null;
|
|
323
334
|
const treeBaseY = groundStart - 1;
|
|
335
|
+
const spriteOverride = options.spriteOverride ?? null;
|
|
324
336
|
for (const tree of forest.trees) {
|
|
325
337
|
const yOffset = getTreeYOffset(tree.id);
|
|
326
|
-
|
|
338
|
+
const sprite = (spriteOverride && spriteOverride.treeId === tree.id)
|
|
339
|
+
? spriteOverride.sprite
|
|
340
|
+
: getSprite(tree.type, tree.growth);
|
|
341
|
+
const canopyShiftX = getWindOffset(tree.id, windTick);
|
|
342
|
+
compositeSprite(buffer, sprite, tree.x, treeBaseY - yOffset, canopyShiftX);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Apply ground overlay (soil reaction during tree birth animation)
|
|
346
|
+
const groundOverlayOpt = options.groundOverlay ?? null;
|
|
347
|
+
if (groundOverlayOpt && groundOverlayOpt.overlays) {
|
|
348
|
+
const groundRow = groundStart; // first ground row
|
|
349
|
+
for (const overlay of groundOverlayOpt.overlays) {
|
|
350
|
+
const ox = groundOverlayOpt.treeX + overlay.dx;
|
|
351
|
+
if (ox >= 0 && ox < virtualWidth && groundRow < buffer.length) {
|
|
352
|
+
buffer[groundRow][ox] = { char: overlay.char, color: overlay.color };
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Ground pulse — brighten all ground pixels by 30% during planting flash
|
|
358
|
+
const groundPulse = options.groundPulse ?? false;
|
|
359
|
+
if (groundPulse) {
|
|
360
|
+
for (let rowIndex = 0; rowIndex < GROUND_ROWS; rowIndex += 1) {
|
|
361
|
+
for (let x = 0; x < virtualWidth; x += 1) {
|
|
362
|
+
const cell = buffer[groundStart + rowIndex][x];
|
|
363
|
+
if (cell.color) {
|
|
364
|
+
const c = parseHex(cell.color);
|
|
365
|
+
cell.color = toHex({
|
|
366
|
+
r: Math.min(255, c.r + (255 - c.r) * 0.3),
|
|
367
|
+
g: Math.min(255, c.g + (255 - c.g) * 0.3),
|
|
368
|
+
b: Math.min(255, c.b + (255 - c.b) * 0.3),
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
327
373
|
}
|
|
328
374
|
|
|
329
375
|
renderGroundDetails(buffer, biome, virtualWidth, groundStart);
|