Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,7 @@ Double-click empty canvas to drop a text object · double-click a shape/line to
its text/label · drop an image file onto the canvas to add it · drag a selected line's
center handle to bend it · resize handles keep a locked aspect ratio (square for shapes,
natural ratio for images).

Any placed shape, icon or image can be switched between a square and a round
pedestal after the fact — via the **Shape** control in the style panel or the
right-click menu — without losing its artwork, label, floor or connected edges.
22 changes: 20 additions & 2 deletions src/interaction/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ import type { Graphics } from "pixi.js";
import {
boundaryPoint,
buildEdgeSiblingIndex,
isRoundFootprint,
type Pt,
quadPoints,
resolveEdgeGeometry,
supportsSilhouette,
} from "../render/geometry";
import {
NAMEPLATE_BACKGROUND_CSS,
Expand Down Expand Up @@ -316,6 +318,22 @@ export class Controller {
{ label: "Send Backward", hint: "[", onSelect: () => actions.sendBackward(ids) },
{ label: "Send to Back", hint: "-", onSelect: () => actions.sendToBack(ids) },
];
const reshapable = ids.map((id) => doc.board.shapes[id]).filter((s) => s && supportsSilhouette(s));
if (reshapable.length) {
const rounds = reshapable.filter(isRoundFootprint).length;
items.push(
{
label: "Square Shape",
hint: rounds === 0 ? "•" : "",
onSelect: () => actions.setShapesSilhouette(ids, "square"),
},
{
label: "Round Shape",
hint: rounds === reshapable.length ? "•" : "",
onSelect: () => actions.setShapesSilhouette(ids, "circle"),
},
);
}
// when the board has named floors, offer to move the selection onto each one
const layers = doc.board.layers ?? [];
if (layers.length > 1) {
Expand Down Expand Up @@ -1396,12 +1414,12 @@ export class Controller {
const s = doc.board.shapes[id];
if (!s) continue;
if (!isShapeInViewport(s, proj, viewport)) continue;
// circle/icon get a ring around the raised disc; rect/image a box around the
// round footprints get a ring around the raised disc; slabs a box around the
// raised slab; text a box on the ground. Everything rides the shape's layer
// elevation so the outline stays glued to a lifted token.
const elev = this.outlineElevation(s); // handles ride this exact plane too
let pts: Pt[];
if (s.kind === "circle" || s.kind === "icon") {
if (isRoundFootprint(s)) {
// the disc is a circle of radius min(w,h)/2, so ring that exact circle
// (padded) rather than the w×h bounding ellipse — keeps the outline
// hugging the disc instead of ballooning into an oval.
Expand Down
3 changes: 2 additions & 1 deletion src/render/culling.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Shape } from "../state/types";
import { isRoundFootprint } from "./geometry";
import { elevationOf, H_PED } from "./shading";
import { type Projector, projectBoard, unprojectBoardAt } from "./projection";
import type { ShapeBounds } from "./shapeSpatialIndex";
Expand Down Expand Up @@ -38,7 +39,7 @@ export function isShapeInViewport(
const rx = shape.w / 2;
const ry = shape.h / 2;
const points: Array<[number, number]> =
shape.kind === "circle" || shape.kind === "icon"
isRoundFootprint(shape)
? [
[cx - rx, cy],
[cx + rx, cy],
Expand Down
20 changes: 16 additions & 4 deletions src/render/geometry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,31 @@ export function center(s: Shape): Pt {
return { x: s.x + s.w / 2, y: s.y + s.h / 2 };
}

/** True when the shape stands on a disc rather than a slab. Falls back to the
* kind default whenever the user hasn't picked a silhouette for it. */
export function isRoundFootprint(s: Shape): boolean {
if (s.silhouette) return s.silhouette === "circle";
return s.kind === "circle" || s.kind === "icon";
}

/** Kinds that raise a pedestal, and so can be reshaped. Text draws no pedestal
* and code draws a titled panel, neither of which has a footprint to swap. */
export function supportsSilhouette(s: Shape): boolean {
return s.kind === "icon" || s.kind === "circle" || s.kind === "rect" || s.kind === "image";
}

/** Point on the shape's outline along the ray from its center toward `target`. */
export function boundaryPoint(s: Shape, target: Pt): Pt {
const c = center(s);
const dx = target.x - c.x;
const dy = target.y - c.y;
if (dx === 0 && dy === 0) return c;
// circle/icon render as a disc, so anchor edges to that circular rim
if (s.kind === "circle" || s.kind === "icon") {
if (isRoundFootprint(s)) {
const r = Math.min(s.w, s.h) / 2;
const len = Math.hypot(dx, dy) || 1;
return { x: c.x + (dx / len) * r, y: c.y + (dy / len) * r };
}
// rect / image / text / code: intersect ray with the half-extent box
// slab footprints: intersect the ray with the half-extent box
const hw = s.w / 2;
const hh = s.h / 2;
const tx = dx !== 0 ? hw / Math.abs(dx) : Infinity;
Expand All @@ -31,7 +43,7 @@ export function boundaryPoint(s: Shape, target: Pt): Pt {
}

export function pointInShape(s: Shape, p: Pt): boolean {
if (s.kind === "circle") {
if (isRoundFootprint(s)) {
const rx = s.w / 2;
const ry = s.h / 2;
const nx = (p.x - (s.x + rx)) / rx;
Expand Down
9 changes: 3 additions & 6 deletions src/render/pedestalBatch.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Container, Mesh, MeshGeometry, Texture } from "pixi.js";
import type { Shape } from "../state/types";
import { hexToNumber, NO_FILL } from "./geometry";
import { hexToNumber, isRoundFootprint, NO_FILL } from "./geometry";
import { type Projector, projectBoard } from "./projection";
import { elevationOf, H_PED, shade, tint } from "./shading";

Expand Down Expand Up @@ -210,11 +210,8 @@ export class PedestalBatch {
sorted.sort((a, b) => b.depth - a.depth);

for (const { shape, alpha } of sorted) {
if (shape.kind === "circle") {
this.addCircle(groups, shape, proj, alpha);
} else if (shape.kind === "rect") {
this.addRect(groups, shape, proj, alpha);
}
if (isRoundFootprint(shape)) this.addCircle(groups, shape, proj, alpha);
else this.addRect(groups, shape, proj, alpha);
}

const ordered = this.orderedGroups;
Expand Down
3 changes: 2 additions & 1 deletion src/render/scene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
type EdgeSiblingIndex,
type Pt,
distToSegment,
isRoundFootprint,
quadPoints,
resolveEdgeGeometry,
} from "./geometry";
Expand Down Expand Up @@ -1078,7 +1079,7 @@ class Scene {
private shapeTopPoly(s: Shape, proj: Projector): Pt[] {
const top = s.kind === "text" ? elevationOf(s) : elevationOf(s) + H_PED;
const out: Pt[] = [];
if (s.kind === "circle" || s.kind === "icon") {
if (isRoundFootprint(s)) {
const cx = s.x + s.w / 2;
const cy = s.y + s.h / 2;
const r = Math.min(s.w, s.h) / 2;
Expand Down
8 changes: 4 additions & 4 deletions src/render/shapeView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
} from "pixi.js";
import type { Shape, ShapeKind } from "../state/types";
import { CODE_THEME_DEFAULT, highlightCode } from "./codeHighlight";
import { hexToNumber, NO_FILL, readableText } from "./geometry";
import { hexToNumber, isRoundFootprint, NO_FILL, readableText } from "./geometry";
import { drawIcon } from "./icons";
import {
ICON_LABEL_FONT_SIZE,
Expand Down Expand Up @@ -289,10 +289,10 @@ function drawSlab(g: Graphics, s: Shape, proj: Projector): void {
}
}

/** Faux-3D pedestal: a disc for circle/icon, a rectangular slab for rect/image/code. */
/** Faux-3D pedestal: a disc or a rectangular slab, per the shape's footprint. */
function drawPedestal(g: Graphics, s: Shape, proj: Projector): void {
if (s.kind === "rect" || s.kind === "image" || s.kind === "code") drawSlab(g, s, proj);
else drawDisc(g, s, proj);
if (isRoundFootprint(s)) drawDisc(g, s, proj);
else drawSlab(g, s, proj);
}

let measureCtx: CanvasRenderingContext2D | null = null;
Expand Down
22 changes: 20 additions & 2 deletions src/state/actions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { center, type Pt, reanchorBend } from "../render/geometry";
import { center, type Pt, reanchorBend, supportsSilhouette } from "../render/geometry";
import { measureTextBox } from "../render/measure";
import { scene } from "../render/scene";
import { STACK_STEP } from "../render/shading";
Expand All @@ -13,7 +13,7 @@ import {
doc,
setSelection,
} from "./store";
import type { Board, Edge, ID, LayerDef, Shape, ShapeKind } from "./types";
import type { Board, Edge, ID, LayerDef, Shape, ShapeKind, Silhouette } from "./types";

export const DEFAULT_SIZE = 110;

Expand Down Expand Up @@ -261,6 +261,24 @@ export function setShapesStyle(
bumpRevision();
}

/**
* Reshape each shape's pedestal. Kinds that raise no pedestal are skipped, so a
* mixed selection reshapes what it can instead of failing. Dimensions are left
* alone: a non-square box turned round draws a disc across its shorter side and
* keeps the box, so switching back restores it exactly.
*/
export function setShapesSilhouette(ids: Iterable<ID>, silhouette: Silhouette): void {
let changed = false;
for (const id of ids) {
const s = doc.board.shapes[id];
if (!s || !supportsSilhouette(s) || s.silhouette === silhouette) continue;
s.silhouette = silhouette;
scene.updateNode(id);
changed = true;
}
if (changed) bumpRevision();
}

/**
* Apply an absolute label/text font size (a size preset) to each shape. Text
* objects are content-sized, so they re-measure their box to the new font
Expand Down
8 changes: 8 additions & 0 deletions src/state/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ export type ID = string;

export type ShapeKind = "rect" | "circle" | "icon" | "image" | "text" | "code";

export type Silhouette = "circle" | "square";

export interface Shape {
id: ID;
kind: ShapeKind;
Expand All @@ -19,6 +21,12 @@ export interface Shape {
src?: string;
/** label/text font size in world units; scales with the object on resize (defaults per kind) */
fontSize?: number;
/**
* Pedestal footprint, chosen by the user after the shape was placed. Absent
* until they pick one, in which case the footprint falls back to the kind
* default — so boards and share links predating this field are unaffected.
*/
silhouette?: Silhouette;
/**
* Named floor this shape sits on (0-based index into `Board.layers`). Each
* distinct value draws as its own glowing board plane. Elevation base is
Expand Down
40 changes: 40 additions & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,41 @@ button {
box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.25);
}

.shape-picker {
display: inline-flex;
gap: 3px;
}
.shape-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 26px;
padding: 0;
color: var(--muted);
background: var(--panel-solid);
border: 1px solid var(--border);
border-radius: 7px;
cursor: pointer;
}
.shape-btn svg {
width: 15px;
height: 15px;
}
.shape-btn:hover:not(:disabled) {
color: var(--text);
border-color: var(--accent);
}
.shape-btn.is-active {
color: var(--text);
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.25);
}
.shape-btn:disabled {
opacity: 0.4;
cursor: default;
}

/* preset-color popover */
.swatch-pop {
position: absolute;
Expand Down Expand Up @@ -1773,6 +1808,11 @@ button {
padding: 0 6px;
}

.shape-btn {
width: 28px;
height: 28px;
}

.icon-palette {
left: 10px;
right: 10px;
Expand Down
37 changes: 35 additions & 2 deletions src/ui/editor.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { centerOrigin, fitToContent, getZoom, isFocusOffBoard, zoomBy } from "../interaction/camera";
import { generateDiagramWithOpenAI } from "../ai/openaiDiagram";
import { Controller } from "../interaction/controller";
import { NO_FILL } from "../render/geometry";
import { isRoundFootprint, NO_FILL, supportsSilhouette } from "../render/geometry";
import { saveNow, startAutosave, stopAutosave } from "../persistence/autosave";
import { listBoards, saveBoard } from "../persistence/db";
import { shareUrl } from "../persistence/share";
Expand All @@ -10,7 +10,7 @@ import * as actions from "../state/actions";
import { $canRedo, $canUndo, disposeHistory, initHistory, redo, undo } from "../state/history";
import { $activeLayer, $camera, $selection, $style, $tool, doc } from "../state/store";
import { TEXT_SIZE_PRESETS } from "../state/style";
import type { Board, ToolName } from "../state/types";
import type { Board, Silhouette, ToolName } from "../state/types";
import { generatedGraphToBoard } from "../state/generatedGraph";
import { createStarterBoard } from "../state/starterBoard";
import { emptyBoard } from "../state/store";
Expand Down Expand Up @@ -38,6 +38,10 @@ const ICON_CODE = svg(
);
const ICON_RECT = svg('<rect x="4" y="6.5" width="16" height="11" rx="2"/>');
const ICON_CIRCLE = svg('<circle cx="12" cy="12" r="7.5"/>');
const SILHOUETTE_OPTIONS: Array<{ value: Silhouette; label: string; icon: string }> = [
{ value: "square", label: "Square", icon: ICON_RECT },
{ value: "circle", label: "Round", icon: ICON_CIRCLE },
];
const ICON_LINE = svg('<path d="M6.5 17.5 17.5 6.5"/><circle cx="6" cy="18" r="2"/><circle cx="18" cy="6" r="2"/>');
const ICON_ARROW = svg('<path d="M5 19 16.5 7.5"/><path d="M16.5 7.5 13.7 14.2"/><path d="M16.5 7.5 9.8 10.3"/>');
const ICON_HAND = svg(
Expand Down Expand Up @@ -457,6 +461,21 @@ export async function mountEditor(
);
const sizePicker = h("div", { class: "size-picker" }, ...sizeButtons);

const shapeButtons = SILHOUETTE_OPTIONS.map((option) =>
h("button", {
class: "shape-btn",
title: `${option.label} shape`,
"aria-label": `${option.label} shape`,
"aria-pressed": "false",
html: option.icon,
onclick: () => {
actions.setShapesSilhouette($selection.get().shapes, option.value);
syncStyle();
},
}),
);
const shapePicker = h("div", { class: "shape-picker" }, ...shapeButtons);

const deleteBtn = h(
"button",
{ class: "btn btn--danger", title: "Delete selection (⌫)", onclick: () => actions.deleteSelection() },
Expand All @@ -468,6 +487,7 @@ export async function mountEditor(
{ class: "style-panel" },
h("div", { class: "field" }, h("span", null, "Fill"), fillPicker.el),
h("div", { class: "field" }, h("span", null, "Size"), sizePicker),
h("div", { class: "field" }, h("span", null, "Shape"), shapePicker),
deleteBtn,
);

Expand All @@ -486,6 +506,19 @@ export async function mountEditor(
btn.classList.toggle("is-active", active);
btn.setAttribute("aria-pressed", String(active));
});
const reshapable = [...sel.shapes]
.map((id) => doc.board.shapes[id])
.filter((shape) => shape && supportsSilhouette(shape));
const footprints = new Set(
reshapable.map((shape): Silhouette => (isRoundFootprint(shape) ? "circle" : "square")),
);
const activeSilhouette = footprints.size === 1 ? [...footprints][0] : null;
shapeButtons.forEach((btn, i) => {
const active = SILHOUETTE_OPTIONS[i].value === activeSilhouette;
btn.classList.toggle("is-active", active);
btn.setAttribute("aria-pressed", String(active));
btn.toggleAttribute("disabled", reshapable.length === 0);
});
const hasSel = sel.shapes.size > 0 || sel.edges.size > 0;
deleteBtn.toggleAttribute("disabled", !hasSel);
stylePanel.classList.toggle("style-panel--editing", hasSel);
Expand Down
Loading