Skip to content
Merged
8 changes: 4 additions & 4 deletions lib/factories.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,12 +293,12 @@ describe.each<RegistrationMode>(["initial", "session"])("%s registration", (mode
const client = await connectClient(mode);
const cases = [
{
args: { name: "invalid", fill_color: "#ffffff" },
error: "The 'layer_name' property is required",
args: { name: "invalid", data: "image.png", fill_color: "#ffffff" },
error: "The 'data' and 'fill_color' properties cannot both be defined",
},
{
args: { name: "invalid", data: "image.png", fill_color: "#ffffff", layer_name: "base" },
error: "The 'data' and 'fill_color' properties cannot both be defined",
args: { name: "invalid", uv_width: 32 },
error: "Supply both uv_width and uv_height",
},
{
args: { name: "invalid", pbr_channel: "normal" },
Expand Down
40 changes: 11 additions & 29 deletions server/tools/paint/brush-stroke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,52 +8,34 @@ export interface IPaintPoint {
}

/**
* Upper bound on brush samples produced for one connected `paint_with_brush`
* call. Interpolating far-apart coordinates pixel by pixel could otherwise
* freeze Blockbench's UI thread or exhaust memory in a single tool call.
* Upper bound on the one-pixel steps of one connected `paint_with_brush`
* stroke. Blockbench's Painter draws a line between consecutive coordinates in
* steps of at most one texture pixel, so far-apart coordinates could otherwise
* freeze its UI thread in a single tool call.
*/
export const MAX_BRUSH_SAMPLES = 100000;

/**
* Expands brush coordinates into the samples the brush should stamp.
*
* With `connect` the stroke is interpolated so consecutive samples are no more
* than one texture pixel apart on either axis (the start point is kept as-is,
* each later coordinate ends its segment); without it the coordinates are
* returned unchanged. The sample total is checked before any interpolation is
* allocated.
* Checks brush coordinates before a native stroke paints them.
*
* @param coordinates - Stroke coordinates in texture pixels, in drawing order.
* @param connect - Whether to interpolate between consecutive coordinates.
* @returns A new array of samples in drawing order.
* @param connect - Whether Painter will draw lines between consecutive coordinates.
* @throws Error when any coordinate is not finite, or when a connected stroke
* would exceed {@link MAX_BRUSH_SAMPLES} samples.
* would take more than {@link MAX_BRUSH_SAMPLES} one-pixel steps.
*/
export function brushStrokeCoordinates(coordinates: IPaintPoint[], connect: boolean): IPaintPoint[] {
export function assertBrushStroke(coordinates: IPaintPoint[], connect: boolean): void {
if (coordinates.some(point => !Number.isFinite(point.x) || !Number.isFinite(point.y))) {
throw new Error("Brush coordinates must be finite texture positions.");
}
if (!connect) return [...coordinates];
// The first coordinate contributes itself; every later one contributes its segment's steps.
const sampleCounts = coordinates.map((point, index) => (index === 0 ? 1 : segmentSteps(coordinates[index - 1], point)));
const totalSamples = sampleCounts.reduce((total, count) => total + count, 0);
if (!connect) return;
// The first coordinate is one stamp; every later one adds its segment's steps.
const totalSamples = coordinates.reduce((total, point, index) => total + (index === 0 ? 1 : segmentSteps(coordinates[index - 1], point)), 0);
if (totalSamples > MAX_BRUSH_SAMPLES) {
throw new Error(`Connected brush strokes exceed ${MAX_BRUSH_SAMPLES} samples. Split the stroke into shorter calls.`);
}
return coordinates.flatMap((point, index) => (
index === 0 ? [point] : interpolateSegment(coordinates[index - 1], point, sampleCounts[index])
));
}

/** Number of one-pixel-or-shorter steps needed to travel from `previous` to `point`. */
function segmentSteps(previous: IPaintPoint, point: IPaintPoint): number {
return Math.ceil(Math.max(Math.abs(point.x - previous.x), Math.abs(point.y - previous.y)));
}

/** Evenly spaced samples after `previous` up to and including `point`; empty when the points coincide. */
function interpolateSegment(previous: IPaintPoint, point: IPaintPoint, steps: number): IPaintPoint[] {
return Array.from({ length: steps }, (_, step) => ({
x: previous.x + (point.x - previous.x) * (step + 1) / steps,
y: previous.y + (point.y - previous.y) * (step + 1) / steps,
}));
}
173 changes: 36 additions & 137 deletions server/tools/paint/brush-tools.ts
Original file line number Diff line number Diff line change
@@ -1,150 +1,34 @@
/// <reference types="three" />
/// <reference types="blockbench-types" />
import type { z } from "zod";
import { createTool } from "@/lib/factories";
import { getAndActivateTexture } from "@/lib/util";
import { paintToolDocs } from "./docs";
import { createBrushPresetParameters, loadBrushPresetParameters, paintWithBrushParameters } from "./schemas";
import { brushStrokeCoordinates, type IPaintPoint } from "./brush-stroke";
import { brushDimensions, brushPresetAliases, matchesBrushPreset, shortBrushPresetName, toOpacityRange } from "./paint-math";
import { assertBrushStroke } from "./brush-stroke";
import { nativePaintStroke, startTextureStroke } from "./native-painter";
import { brushPresetAliases, matchesBrushPreset, normalizeHexColor, shortBrushPresetName, toOpacityRange } from "./paint-math";
import {
activeOpacityRange,
applyToolbarValues,
brushOpacityForToolbar,
ensurePaintableLayer,
painterRuntime,
type IBrushPreset,
} from "./runtime";

/** Brush settings as accepted by `paint_with_brush`; the whole object is optional. */
type BrushSettings = z.infer<typeof paintWithBrushParameters>["brush_settings"];

/** Fully resolved brush state applied to the toolbar and stamped onto the canvas. */
interface IBrushStyle {
color: string;
red: number;
green: number;
blue: number;
/** Opacity on the public 0-255 scale. */
alpha: number;
size: number;
/** Softness percentage, 0-100. */
softness: number;
shape: "square" | "circle";
aspectRatio: number;
}

/** RGBA pixel as passed to and returned from Painter's per-pixel callbacks (alpha 0-1). */
interface IRgbaPixel {
r: number;
g: number;
b: number;
a: number;
}

/** Per-pixel callback of `Painter.editSquare` / `editCircle`. */
type PixelEditor = (pixel: IRgbaPixel, opacity: number, px: number, py: number) => IRgbaPixel;

/**
* `Painter.editSquare` / `editCircle` as implemented in Blockbench 5.2: the
* radius may also be a `[width, height]` pair (used for brush aspect ratio),
* which blockbench-types does not declare.
*/
type BrushStamp = (
ctx: CanvasRenderingContext2D,
x: number,
y: number,
size: number | [number, number],
softness: number,
editPx: PixelEditor
) => void;

/** Values used for any brush setting the caller omits: a hard, opaque, 1px black square. */
const BRUSH_DEFAULTS = {
color: "#000000",
opacity: 255,
size: 1,
softness: 0,
shape: "square",
aspectRatio: 0,
} as const;

/** Blockbench scales normalized softness by this factor before stamping (see `Painter.useBrush`). */
const NATIVE_SOFTNESS_FACTOR = 1.8;

/** Fills omitted brush settings with defaults and parses the `#RRGGBB` color into channels. */
function resolveBrushStyle(settings: BrushSettings): IBrushStyle {
const color = settings?.color ?? BRUSH_DEFAULTS.color;
return {
color,
red: parseInt(color.slice(1, 3), 16),
green: parseInt(color.slice(3, 5), 16),
blue: parseInt(color.slice(5, 7), 16),
alpha: settings?.opacity ?? BRUSH_DEFAULTS.opacity,
size: settings?.size ?? BRUSH_DEFAULTS.size,
softness: settings?.softness ?? BRUSH_DEFAULTS.softness,
shape: settings?.shape ?? BRUSH_DEFAULTS.shape,
aspectRatio: settings?.aspect_ratio ?? BRUSH_DEFAULTS.aspectRatio,
};
}

/** Mirrors the brush onto Blockbench's toolbar so the UI reflects what was painted. */
function applyBrushStyle(style: IBrushStyle): void {
applyToolbarValues({
slider_brush_size: style.size,
slider_brush_opacity: toOpacityRange(style.alpha, activeOpacityRange()),
slider_brush_softness: style.softness,
brush_shape: style.shape,
});
ColorPanel.set(style.color);
}

/** The part of a pixel layer the brush needs: growing its canvas to fit a stamp. */
interface IExpandableLayer {
expandTo(...points: [number, number][]): void;
}

/** Narrows an active layer to one that exposes `expandTo` (pixel layers since 5.0). */
function isExpandableLayer(layer: unknown): layer is IExpandableLayer {
return typeof layer === "object" && layer !== null && typeof Reflect.get(layer, "expandTo") === "function";
}

/**
* Grows the active pixel layer so every stamp fits, like `Painter.useBrush`
* does before each dab; a layer smaller than the texture would otherwise clip
* strokes that reach past its canvas. No-op when layers are disabled.
* Oldest Blockbench version trusted to stamp a `movePaintTool(..., new_face = true)`
* move without a line from the previous point, as the 5.2.1 source does; older
* hosts paint one stroke per point instead.
*/
function expandActiveLayer(texture: Texture, points: readonly IPaintPoint[], size: number): void {
if (!texture.layers_enabled) return;
const layer: unknown = texture.getActiveLayer();
if (!isExpandableLayer(layer)) return;
points.forEach(({ x, y }) => layer.expandTo([x - size + 1, y - size + 1], [x + size, y + size]));
}
const SEPARATE_DABS_MIN_VERSION = "5.2.0";

/**
* Stamps every sample in order with the brush shape; runs inside `Texture.edit`.
*
* Coordinates are texture pixels and are passed through unchanged:
* `Painter.scanCanvas` already subtracts the active layer's offset. Each pixel
* is source-over blended with the brush color at the brush opacity times the
* shape falloff, like Blockbench's own brush.
*/
function stampBrushSamples(texture: Texture, canvas: HTMLCanvasElement, points: IPaintPoint[], style: IBrushStyle): void {
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Brush painting requires a 2D canvas context.");
const stamp = (style.shape === "circle" ? Painter.editCircle : Painter.editSquare) as unknown as BrushStamp;
expandActiveLayer(texture, points, style.size);
const brushColor = { r: style.red, g: style.green, b: style.blue };
const strength = style.alpha / 255;
const editPixel: PixelEditor = (pixel, falloff) =>
Painter.combineColors(pixel, { ...brushColor, a: 1 }, strength * falloff);
const size = style.aspectRatio ? brushDimensions(style.size, style.aspectRatio) : style.size;
const softness = (style.softness / 100) * NATIVE_SOFTNESS_FACTOR;
points.forEach(point => stamp.call(Painter, ctx, point.x, point.y, size, softness, editPixel));
}

/**
* Registers `paint_with_brush` (`paintToolDocs[7]`): stamps brush samples
* inside one `Texture.edit`, which owns the undo entry.
* Registers `paint_with_brush` (`paintToolDocs[7]`): native strokes with
* Blockbench's brush tool, so blend mode, pixel-perfect drawing, lock alpha,
* mirror painting and every other brush setting apply as when painting by
* hand. Given settings are written to the toolbar first; omitted ones keep the
* current brush. Painter owns each stroke's undo entry.
*/
export function registerPaintWithBrushTool(): void {
createTool(
Expand All @@ -153,17 +37,32 @@ export function registerPaintWithBrushTool(): void {
...paintToolDocs[7],
parameters: paintWithBrushParameters,
async execute({ texture_id, coordinates, brush_settings, connect_strokes }) {
const points = brushStrokeCoordinates(coordinates, connect_strokes);
assertBrushStroke(coordinates, connect_strokes);
const color = brush_settings?.color === undefined ? undefined : normalizeHexColor(brush_settings.color);
const texture = getAndActivateTexture(texture_id);
ensurePaintableLayer(texture);
const style = resolveBrushStyle(brush_settings);
applyBrushStyle(style);

texture.edit(
(canvas: HTMLCanvasElement) => stampBrushSamples(texture, canvas, points, style),
{ edit_name: "Paint with brush" }
);

// Select the tool first: slider values are stored per tool.
// @ts-ignore
BarItems.brush_tool.select();
if (color) ColorPanel.set(color);
applyToolbarValues({
slider_brush_size: brush_settings?.size,
slider_brush_opacity: brushOpacityForToolbar(brush_settings?.opacity),
slider_brush_softness: brush_settings?.softness,
slider_brush_aspect_ratio: brush_settings?.aspect_ratio,
brush_shape: brush_settings?.shape,
blend_mode: brush_settings?.blend_mode,
});

// A move flagged as a new face stamps without a connecting line, so separate dabs can share one stroke.
// Older hosts paint one stroke per point instead, so the points never turn into lines.
const oneStroke = connect_strokes || !Blockbench.isOlderThan(SEPARATE_DABS_MIN_VERSION);
const strokes = oneStroke ? [coordinates] : coordinates.map(point => [point]);
strokes.forEach(([first, ...rest]) => nativePaintStroke(
painter => startTextureStroke(painter, texture, first.x, first.y),
painter => rest.forEach(point => painter.movePaintTool(texture, point.x, point.y, {}, !connect_strokes))
));
Canvas.updateAll();

return `Painted ${coordinates.length} points on texture "${texture.name}"`;
Expand Down
9 changes: 5 additions & 4 deletions server/tools/paint/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export const paintToolDocs: IToolSpec[] = [
name: "paint_fill_tool",
condition: { project: true, features: ["paint_mode"], method: () => Texture.all.length > 0 && Boolean(BarItems.fill_tool) && Condition(BarItems.fill_tool.condition) },
description:
"Uses the fill/bucket tool to fill areas with color. In 'face' and 'element' fill modes only the face or element whose UV area contains the given texture pixel is filled. Opacity is 0-255 regardless of Blockbench's opacity range setting.",
"Uses the fill/bucket tool to fill areas with color. In 'face' and 'element' fill modes only the face or element whose UV area contains the given texture pixel is filled, and a pixel no face covers is an error. Seeds outside the texture are rejected, and so are color and color_connected seeds outside the active layer, where the fill reads its seed color. Opacity is 0-255 regardless of Blockbench's opacity range setting.",
annotations: {
title: "Paint Fill Tool",
destructiveHint: true,
Expand Down Expand Up @@ -63,10 +63,11 @@ export const paintToolDocs: IToolSpec[] = [
name: "color_picker_tool",
condition: { project: true, features: ["paint_mode"], method: () => Texture.all.length > 0 },
description:
"Picks colors from textures and sets them as the active color.",
"Picks a texture pixel's color and makes it the primary or secondary color, like Blockbench's color picker. It does not edit the texture, but it changes the active colors, selects the texture, and depending on Blockbench's settings can set the brush opacity or switch the active paint tool.",
annotations: {
title: "Color Picker Tool",
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
parameters: colorPickerToolParameters,
status: STATUS_EXPERIMENTAL,
Expand Down Expand Up @@ -109,7 +110,7 @@ export const paintToolDocs: IToolSpec[] = [
name: "paint_with_brush",
condition: { project: true, features: ["paint_mode"], method: () => Texture.all.length > 0 },
description:
"Paints on textures using the brush tool with customizable settings (opacity 0-255, softness 0-100%, optional aspect ratio). Paints on the selected pixel layer; if a layer group is selected, a pixel layer inside it is used.",
"Paints with Blockbench's own brush tool, so blend mode, pixel-perfect drawing, lock alpha and mirror painting apply as when painting by hand. Given brush settings (opacity 0-255, softness 0-100%, hex or named color, shape, blend mode, aspect ratio) are set on the brush first; omitted ones keep the current brush. Paints on the selected pixel layer; if a layer group is selected, a pixel layer inside it is used.",
annotations: {
title: "Paint with Brush",
destructiveHint: true,
Expand Down
Loading
Loading