Lição 25 - Modo de desenho e pincel
Na Lição 14 - Modo da tela e interface auxiliar apresentamos os modos mão e de seleção. Nesta lição, vamos apresentar os modos de desenho: retângulos, elipses e setas, além dos modos de pincel, mais livres.
Modo de desenhar retângulo
Primeiro, adicione o seguinte modo da tela. A implementação do desenho de elipses é quase idêntica, então não vou repetir a apresentação:
export enum Pen {
HAND = 'hand',
SELECT = 'select',
DRAW_RECT = 'draw-rect',
DRAW_Ellipse = 'draw-ellipse',
}Na Lição 18 - Refatorar com ECS apresentamos a arquitetura ECS, em que um System DrawRect é criado e, ao entrar nesse modo, o estilo do cursor passa a ser crosshair:
import { System } from '@lastolivegames/becsy';
export class DrawRect extends System {
execute() {
if (pen !== Pen.DRAW_RECT) {
return;
}
const input = canvas.write(Input);
const cursor = canvas.write(Cursor);
cursor.value = 'crosshair';
//...
}
}Em seguida, enquanto o mouse é arrastado, o retângulo é redesenhado continuamente na área de destino, de forma parecida com o efeito de seleção por caixa no modo de seleção. Quando o mouse é solto e a criação do retângulo termina, o modo passa de desenhar retângulo para seleção:
export class DrawRect extends System {
execute() {
//...
// Draw rect brush when dragging
this.handleBrushing(api, x, y);
if (input.pointerUpTrigger) {
// Create rect when pointerup event triggered
const node: RectSerializedNode = {
id: uuidv4(),
type: 'rect', // Change to 'ellipse' in draw-ellipse mode
x,
y,
width,
height,
};
api.setAppState({ penbarSelected: Pen.SELECT }); // Switch canvas mode
api.updateNode(node);
api.record(); // Save to history
}
}
}A seguir, vemos o que acontece durante o arraste.
Redesenhar o retângulo
Assim como na seleção por caixa, para evitar que um arraste curto já comece o desenho, precisamos definir um limiar, calculado no sistema de coordenadas do Viewport:
handleBrushing(api: API, viewportX: number, viewportY: number) {
const camera = api.getCamera();
const {
pointerDownViewportX,
pointerDownViewportY,
} = camera.read(ComputedCameraControl);
// Use a threshold to avoid showing the selection brush when the pointer is moved a little.
const shouldShowSelectionBrush =
distanceBetweenPoints(
viewportX,
viewportY,
pointerDownViewportX,
pointerDownViewportY,
) > 10;
}As coordenadas x/y do retângulo auxiliar são o ponto em que pointerdown foi disparado, e as coordenadas do objeto do evento pointermove precisam ser convertidas para o sistema de coordenadas da tela para calcular a largura e a altura:
const { x: cx, y: cy } = api.viewport2Canvas({
x: viewportX,
y: viewportY,
});
let x = pointerDownCanvasX;
let y = pointerDownCanvasY;
let width = cx - x;
let height = cy - y;
api.updateNode(
selection.brush,
{
visibility: 'visible',
x,
y,
width,
height,
},
false,
);Vale a pena considerar o arraste no sentido inverso: width/height calculados podem ser negativos, e o x/y correspondente deixa de estar na posição do pointerdown e precisa ser recalculado. O Figma faz o mesmo:
if (width < 0) {
x += width;
width = -width;
}
if (height < 0) {
y += height;
height = -height;
}Rótulo de tamanho
Queremos mostrar as dimensões do retângulo em tempo real durante o desenho, como o Figma faz:

Da mesma forma, anotações nesse tipo de espaço de viewport cabem bem em contêineres SVG, como mostram as ferramentas de ponteiro laser e borracha descritas abaixo. Os rótulos retangulares usam estilos CSS para definir cor de fundo, padding, centralização e outras propriedades, enquanto o texto usa a largura e a altura da caixa delimitadora:
label.style.visibility = 'visible';
label.style.top = `${y + height}px`;
label.style.left = `${x + width / 2}px`;
label.innerText = `${Math.round(width)} × ${Math.round(height)}`;Se você estiver desenhando uma linha reta, os rótulos podem girar na direção da linha, garantindo que o texto sempre fique voltado para a frente. Dá para experimentar isso no exemplo da seta na próxima seção:
label.style.top = `${y + height / 2}px`;
const rad = Math.atan2(height, width);
let deg = rad * (180 / Math.PI);
if (deg >= 90 && deg <= 180) {
deg = deg - 180;
} else if (deg <= -90 && deg >= -180) {
deg = deg + 180;
}
label.style.transform = `translate(-50%, -50%) rotate(${deg}deg)`;Desenhar quadrado perfeito com Shift
Mantenha Shift pressionado ao arrastar para criar quadrados, círculos e polígonos perfeitos. Nesse caso, o menor valor absoluto entre a largura e a altura é usado como o lado do quadrado.
if (isSquare) {
if (Math.abs(width) > Math.abs(height)) {
width = Math.sign(width) * Math.abs(height);
} else {
height = Math.sign(height) * Math.abs(width);
}
}Desenhar seta
Além de formas básicas como retângulos, elipses e polilinhas, formas compostas como setas também são comuns. Não vamos tratar agora das relações de vínculo da seta (em que a direção muda quando as formas ligadas pela ponta e pela cauda se movem); esse tema fica para um capítulo separado. Aqui, o foco é apenas como as setas são desenhadas.
As setas são declaradas primeiro no SVG com um <marker>, em geral um <path>, e depois associadas por meio dos atributos marker-start e marker-end do gráfico de destino:
<defs>
<!-- arrowhead marker definition -->
<marker
id="arrow"
viewBox="0 0 10 10"
refX="5"
refY="5"
markerWidth="6"
markerHeight="6"
orient="auto-start-reverse"
>
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
</defs>
<!-- Coordinate axes with a arrowhead in both direction -->
<polyline
points="10,10 10,90 90,90"
fill="none"
stroke="black"
marker-start="url(#arrow)"
marker-end="url(#arrow)"
/>Separar as extremidades da seta do corpo é bem flexível. Em um editor gráfico, porém, basta oferecer alguns estilos comuns predefinidos. No Figma, por exemplo, a seta é aplicada às duas extremidades do Path (start/end point), com estilos predefinidos como line/triangle/diamond. Veja How to Curve an Arrow in Figma.

O drawio também oferece muitos estilos de seta prontos para usar:

No uso declarativo, é aceitável abrir mão de estilos de seta personalizados e oferecer um conjunto de literais de estilo embutidos, que geram as extremidades junto com o corpo ao construir a Polyline / o Path. Essa ideia também aparece em plot - arrow, renderizado com SVG, que não usa <marker>, e sim uma definição completa de <path>.
export interface MarkerAttributes {
markerStart: Marker['start'];
markerEnd: Marker['end'];
}A seguir, vejamos o processo de construção da geometria.
Ponto inicial e final
Primeiro, você precisa encontrar os pontos inicial e final das setas.
A orientação, porém, precisa ser calculada manualmente. Não é complicado: ela segue a reta tangente.
Exportar seta para SVG
No SVG, você pode ajustar a orientação do <marker> com o atributo orient, mas esse atributo só tem dois valores literais: ‘auto’ e ‘auto-start-reverse’.
if (isEnd) {
$marker.setAttribute('orient', 'auto');
} else {
$marker.setAttribute('orient', 'auto-start-reverse');
}Em seguida, crie um <path> de acordo com o tipo do marcador, para que ele herde propriedades como stroke do gráfico de destino. O SVG completo está no caso de teste export arrow:
if (marker === 'line') {
const points = lineArrow(0, 0, arrowRadius, Math.PI);
const $path = createSVGElement('path');
$path.setAttribute('fill', 'none');
$path.setAttribute('stroke', stroke);
$path.setAttribute('stroke-width', `${strokeWidth}`);
$marker.appendChild($path);
}Por outro lado, os arquivos SVG exportados também precisam poder ser importados de novo para a tela.
Desenhar polígono
Dada uma caixa delimitadora retangular, gere por código um caminho poligonal:
function regularPolygonPathInRect(
sides: number,
width: number,
height: number,
rotation = -Math.PI / 2,
): string {
if (sides < 3 || width <= 0 || height <= 0) {
return '';
}
const cx = width / 2;
const cy = height / 2;
const rx = width / 2;
const ry = height / 2;
const step = (Math.PI * 2) / sides;
const points: [number, number][] = [];
for (let i = 0; i < sides; i++) {
const angle = rotation + i * step;
points.push([
formatNumber(cx + Math.cos(angle) * rx),
formatNumber(cy + Math.sin(angle) * ry),
]);
}
return points
.map(([x, y], index) => `${index === 0 ? 'M' : 'L'} ${x} ${y}`)
.join(' ')
.concat(' Z');
}Ferramenta lápis
Vamos começar pela implementação mais simples, que exibe uma linha quebrada, chamada Pencil no Figma.
Para reduzir a quantidade de vértices gerados pelo arraste, sobretudo vértices duplicados ou muito próximos, vamos simplificar a polilinha com o método descrito em Lição 12 - Simplificar a polilinha, usando a implementação simplify-js. Vale notar a definição do parâmetro tolerance, que afeta o grau de simplificação:
Afeta a quantidade de simplificação (na mesma unidade das coordenadas dos pontos).
Queremos definir tolerance de acordo com o nível de zoom atual da câmera. Caso contrário, o tremor causado pela simplificação excessiva em zooms altos fica fácil de ver:

import simplify from 'simplify-js';
export class DrawPencil extends System {
private handleBrushing() {
// choose tolerance based on the camera zoom level
const tolerance = 1 / zoom;
selection.points = simplify(selection.pointsBeforeSimplify, tolerance);
}
}Traço livre perfeito
Como a largura da linha é fixa, falta uma sensação de “flexibilidade”. Para um traço à mão, a linha pode variar com a pressão. Para isso, dá para usar perfect-freehand. Esse recurso ainda não está integrado ao Excalidraw; veja: Perfect Freehand Drawing Issue. Como a largura da linha é variável, a forma final deixa de ser uma Polyline e passa a ser um Path:
import { getStroke } from 'perfect-freehand';
const outlinePoints = getStroke(points);
const d = getSvgPathFromStroke(outlinePoints); // 'M 0 0 L...'Por padrão, perfect-freehand simula a velocidade das pinceladas calculando larguras variáveis a partir da distância entre pontos adjacentes. Para usar a pressure real, como a de uma caneta ou stylus, informe a pressão como o terceiro número de cada ponto de entrada:
export class Input {
@field.float32 declare pressure: number;
}E defina a opção simulatePressure como false.
const outlinePoints = getStroke(inputPoints, {
simulatePressure: false,
});Modo pincel
Você pode escolher essa subferramenta ao entrar no modo Paint no Photoshop Web e desenhar traços arrastando de forma contínua:

No Figma, isso se chama Draw with illustration tools.
Olhando de perto esse tipo de traço, ele é um conjunto de pontos consecutivos. Se os raios forem diferentes, surge o efeito de espessura variável. Isso pode ser feito mapeando a pressão do pincel para o raio:

Aqui seguimos o Brush Rendering Tutorial para obter esse efeito.
Implementação básica
A estrutura de dados de base é a seguinte:
interface BrushPoint {
x: number;
y: number;
radius: number;
}Os
renderPass.drawIndexed(6, points.length - 1); // indices: [0, 1, 2, 0, 2, 3]Como vimos na Lição 12 - Extrudar o segmento, você pode passar a_VertexNum para o Vertex Shader ou usar gl_VertexID diretamente, como no Brush Rendering Tutorial, se não precisar considerar a compatibilidade com WebGL 1:
layout(location = ${Location.POINTA}) in vec3 a_PointA;
layout(location = ${Location.POINTB}) in vec3 a_PointB;
layout(location = ${Location.VERTEX_NUM}) in float a_VertexNum; // [0, 1, 2, 3]Os outros attributes, a_PointA e a_PointB, guardam raios variáveis além das coordenadas de posição dos vértices. De novo usamos vertexBufferOffsets para multiplexar o mesmo Buffer, e a_PointB lê a partir de um deslocamento de 4 * 3. Assim temos os números de vértice para esticar no Vertex Shader:

vec2 position;
vec2 offsetSign;
float r;
if (vertexNum < 0.5) {
position = p0;
r = r0;
offsetSign = vec2(-1.0, -1.0);
} else if (vertexNum < 1.5) {
position = p0;
r = r0;
offsetSign = vec2(-1.0, 1.0);
}Para dar suporte a larguras variáveis, a distância esticada nem sempre é igual ao raio do ponto atual. Ela precisa ser calculada a partir da inclinação do segmento de linha:

int MAX_i = 128; float currIndex = startIndex;
float A = 0.0;
for(int i = 0; i < MAX_i; i++){
// Blend opacity
A = A * (1.0-opacity) + opacity;
}O efeito é o seguinte. Selecione a ferramenta pincel e varie a velocidade do arraste: o ECS usa amostras de mouse/toque com carimbo de tempo para deixar traços rápidos mais finos e traços lentos mais grossos, com transições suaves. Uma caneta sensível à pressão usa a pressão medida. Os raios resultantes ficam armazenados no traço, então desfazer/refazer e recarregar preservam a forma.
Carimbo
Isso ainda não funciona como uma pincelada de verdade. Adicionamos a propriedade brushStamp para a URL da imagem do carimbo, mas ela só controla a opacidade do pincel; stroke continua definindo a cor.
api.updateNodes([
{
id: '1',
type: 'brush',
brushType: BrushType.STAMP,
brushStamp: '/stamp.png',
points: position.map(([x, y], i) => `${x},${y},${radius[i]}`).join(' '),
stroke: 'grey',
},
]);No momento, todas as texturas são aplicadas com orientação fixa. Podemos adicionar ângulos de rotação aleatórios às texturas:
float angle = rotationFactor*radians(360.0*fract(sin(currIndex)*1.0));
pToCurrStamp *= rotate(angle);Também podemos usar um fator de ruído ao misturar a opacidade:
float opacityNoise = noiseFactor*fbm(textureCoordinate*50.0);[WIP] Exportar SVG
O Figma consegue exportar o pincel para SVG.
Ponteiro laser
Ao implementar recursos de apresentação na tela, o ponteiro laser é uma função essencial. O Excalidraw oferece esse recurso, como detalhado em: laser pointer. Dá para usar direto o módulo encapsulado @excalidraw/laser-pointer, que gera um caminho de trajetória a partir de um conjunto de coordenadas de pontos. No núcleo, essa função também usa perfect-freehand.
Esse recurso difere das formas desenhadas antes na tela nos seguintes pontos:
- A renderização da trajetória do ponteiro laser ocorre no sistema de coordenadas do viewport, independente do nível de zoom atual da tela. Isso permite implementá-la em um contêiner HTML separado. Veja: Lição 29 - Contêiner HTML
- Os traços somem sozinhos depois de um tempo definido
- Pensado para cenários de colaboração com vários usuários. Veja: Lição 20 - Awareness e Presence
Borracha
O Excalidraw oferece a freedraw eraser. Depois de escolher a ferramenta borracha, as formas percorridas pelo cursor do mouse aparecem “desbotadas”, indicando que estão prestes a ser apagadas. Soltar o mouse conclui o apagamento.
Na implementação, usamos a técnica Perfect freehand da seção anterior para desenhar o caminho do arraste, mas só guardamos os 4 pontos mais recentes. Ao mesmo tempo, detectamos em tempo real as formas percorridas, ajustamos a opacidade delas (lembrando de salvar a opacidade original para restaurar se a operação for cancelada) e removemos as formas selecionadas quando o mouse é solto:
export class DrawEraser extends System {
execute() {
if (input.pointerUpTrigger) {
api.runAtNextTick(() => {
api.updateNode(brush, { visibility: 'hidden' }, false);
api.setAppState({
penbarSelected: Pen.SELECT,
});
api.deleteNodesById(
Array.from(selected).map((e) => api.getNodeByEntity(e)?.id),
);
api.record();
});
}
}
}Não atômico
Apagar formas inteiras basta na maior parte dos casos, mas o apagamento não atômico se mostra mais útil no desenho à mão livre, por exemplo ao cortar uma linha reta no meio. O Excalidraw ainda não tem esse recurso; veja: non-atomic erasing for linear & freedraw shapes. O FigJam tem a mesma limitação. Se a tela for renderizada com Canvas ou SVG, esse efeito de apagamento no nível do pixel de fato não é possível.
Como a nossa tela é implementada com WebGL/WebGPU, a técnica mais adequada é o stencil buffer. Primeiro, desenhe o caminho da borracha no stencil buffer e depois use-o como máscara para redesenhar a cena. Tomando o OpenGL como exemplo (o WebGL é totalmente análogo):
// 1. Disable color/depth writes, enable stencil, and draw the eraser shape:
// This writes “1” into the stencil where the eraser stamp is drawn.
glColorMask(false,false,false,false);
glDepthMask(false);
glEnable(GL_STENCIL_TEST);
glStencilFunc(GL_ALWAYS, 1, 0xFF);
glStencilOp(GL_REPLACE, GL_REPLACE, GL_REPLACE);
// draw eraser brush geometry here (e.g. a textured quad or circle)
glColorMask(true,true,true,true);
glDepthMask(true);
// 2. Now render the shapes with stencil test=EQUAL, so only pixels where stencil==1 pass:
glStencilFunc(GL_EQUAL, 1, 0xFF);
glStencilOp(GL_KEEP, GL_KEEP, GL_KEEP);
// draw all scene objects (they will only appear where eraser just wrote 1)Vamos apresentar isso mais adiante na Lição 34 - Frame e recorte.