From 7c59b077d8d85db166ee9aada23568c6afa6dc3b Mon Sep 17 00:00:00 2001 From: Amark19 Date: Mon, 24 Aug 2026 20:11:48 +0530 Subject: [PATCH 1/2] feat: cylinder (database) shape tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a database-cylinder primitive: toolbar button, drag-to-size draw, and full style support. It's a plain fabric.Path so it styles (stroke/fill/width/dash) and binds like any other shape — arrows attach to its border and follow it. The rim arcs bulge to the box edges but fabric's arc bbox misses the bottom bulge, so width/height/pathOffset are set to the true box (selection + binding match what's drawn). Co-Authored-By: Claude Opus 4.8 --- src/Handlers/ToolsHandler/assignUtil.js | 2 + src/Handlers/ToolsHandler/tools/cylinder.js | 88 +++++++++++++++++++ .../ToolsHandler/tools/cylinder.test.js | 52 +++++++++++ src/Handlers/ToolsHandler/tools/index.js | 1 + .../PropertiesPanel/PropertiesPanel.jsx | 1 + src/constants/IconTools.js | 2 + src/constants/tools.js | 5 ++ 7 files changed, 151 insertions(+) create mode 100644 src/Handlers/ToolsHandler/tools/cylinder.js create mode 100644 src/Handlers/ToolsHandler/tools/cylinder.test.js diff --git a/src/Handlers/ToolsHandler/assignUtil.js b/src/Handlers/ToolsHandler/assignUtil.js index 59452e6..fd00cb2 100644 --- a/src/Handlers/ToolsHandler/assignUtil.js +++ b/src/Handlers/ToolsHandler/assignUtil.js @@ -4,6 +4,7 @@ import { Font, Diamond, Polygon, + Cylinder, Arrow, Line, Eraser, @@ -16,6 +17,7 @@ const toolFactory = { [TOOL_CONSTANTS.FONT]: new Font(), [TOOL_CONSTANTS.DIAMOND]: new Diamond(), [TOOL_CONSTANTS.POLYGON]: new Polygon(), + [TOOL_CONSTANTS.CYLINDER]: new Cylinder(), [TOOL_CONSTANTS.ARROW]: new Arrow(), [TOOL_CONSTANTS.LINE]: new Line(), [TOOL_CONSTANTS.ERASER]: new Eraser(), diff --git a/src/Handlers/ToolsHandler/tools/cylinder.js b/src/Handlers/ToolsHandler/tools/cylinder.js new file mode 100644 index 0000000..0c775d9 --- /dev/null +++ b/src/Handlers/ToolsHandler/tools/cylinder.js @@ -0,0 +1,88 @@ +import { fabric } from "fabric"; +import { Tool } from "../toolGeneric"; +import { resolveToolStyle } from "../toolStyle"; + +// Default size for a plain click (no drag), like the other shapes. +const DEFAULT_W = 80; +const DEFAULT_H = 100; + +// SVG path for a database cylinder in a w x h box (top-left origin). The cap +// ellipse stays a shallow lid across aspect ratios. `body` is the closed +// silhouette (back top rim -> right -> bottom front curve); `lid` is the front +// rim arc, stroked on top so the lid reads. +const cylinderPath = (w, h) => { + const rx = w / 2; + const ry = Math.max(1, Math.min(h * 0.16, w * 0.35, h / 2 - 0.5)); + const top = ry; + const bottom = h - ry; + const body = `M 0 ${top} A ${rx} ${ry} 0 0 1 ${w} ${top} L ${w} ${bottom} A ${rx} ${ry} 0 0 0 0 ${bottom} Z`; + const lid = `M 0 ${top} A ${rx} ${ry} 0 0 0 ${w} ${top}`; + return `${body} ${lid}`; +}; + +export class Cylinder extends Tool { + constructor() { + super(); + this.origX = null; + this.origY = null; + this.pointer = null; + this.cylinder = null; + } + + // Rewrite the path for the given box and pin its top-left, so the shape grows + // from the drag start (like the rectangle) without accumulating scale. The + // rim arcs bulge to exactly the box edges, but fabric's arc bbox misses the + // bottom bulge — set width/height/pathOffset to the true box so selection and + // binding match what's drawn. + applyBox(left, top, width, height) { + this.cylinder._setPath(fabric.util.parsePath(cylinderPath(width, height))); + this.cylinder.set({ + width, + height, + pathOffset: { x: width / 2, y: height / 2 }, + left, + top, + }); + this.cylinder.setCoords(); + } + + create(canvas, event) { + this.pointer = canvas.getPointer(event.e); + this.origX = this.pointer.x; + this.origY = this.pointer.y; + this.cylinder = new fabric.Path(cylinderPath(1, 1), { + left: this.origX, + top: this.origY, + originX: "left", + originY: "top", + ...resolveToolStyle(canvas), + objectCaching: false, // recompute the bounding box as it is dragged + selectable: true, + }); + canvas.add(this.cylinder); + } + + draw(canvas, event) { + if (!this.cylinder) { + return; + } + this.pointer = canvas.getPointer(event.e); + // min() (not abs) so dragging up/left works in negative scene coords too. + const left = Math.min(this.origX, this.pointer.x); + const top = Math.min(this.origY, this.pointer.y); + const width = Math.max(Math.abs(this.origX - this.pointer.x), 1); + const height = Math.max(Math.abs(this.origY - this.pointer.y), 1); + this.applyBox(left, top, width, height); + } + + done() { + if (!this.cylinder) { + return; + } + // A plain click leaves a near-zero shape — drop a default-sized one instead. + if (this.cylinder.width < 5 || this.cylinder.height < 5) { + this.applyBox(this.origX, this.origY, DEFAULT_W, DEFAULT_H); + } + this.cylinder.setCoords(); + } +} diff --git a/src/Handlers/ToolsHandler/tools/cylinder.test.js b/src/Handlers/ToolsHandler/tools/cylinder.test.js new file mode 100644 index 0000000..3197165 --- /dev/null +++ b/src/Handlers/ToolsHandler/tools/cylinder.test.js @@ -0,0 +1,52 @@ +import { Cylinder } from "./cylinder"; +import { isBindable } from "../../../utils/binding"; + +// Minimal canvas stub: the tool only needs getPointer + add. +const mockCanvas = () => { + const objects = []; + return { + objects, + add: (o) => objects.push(o), + getPointer: (e) => ({ x: e.clientX, y: e.clientY }), + }; +}; +const evt = (x, y) => ({ e: { clientX: x, clientY: y } }); + +const drawCylinder = (from, to) => { + const canvas = mockCanvas(); + const tool = new Cylinder(); + tool.create(canvas, evt(from.x, from.y)); + tool.draw(canvas, evt(to.x, to.y)); + tool.done(canvas); + return { canvas, shape: canvas.objects[0] }; +}; + +describe("Cylinder tool", () => { + test("drag-to-size builds a path sized to the drag box", () => { + const { shape } = drawCylinder({ x: 100, y: 100 }, { x: 260, y: 300 }); + expect(shape.type).toBe("path"); + expect(Math.round(shape.width)).toBe(160); + expect(Math.round(shape.height)).toBe(200); + expect(Math.round(shape.left)).toBe(100); + expect(Math.round(shape.top)).toBe(100); + }); + + test("dragging up/left still sizes from the top-left corner", () => { + const { shape } = drawCylinder({ x: 300, y: 300 }, { x: 200, y: 220 }); + expect(Math.round(shape.left)).toBe(200); + expect(Math.round(shape.top)).toBe(220); + expect(Math.round(shape.width)).toBe(100); + expect(Math.round(shape.height)).toBe(80); + }); + + test("a plain click (no drag) drops a default-sized cylinder", () => { + const { shape } = drawCylinder({ x: 50, y: 50 }, { x: 51, y: 51 }); + expect(shape.width).toBeGreaterThan(10); + expect(shape.height).toBeGreaterThan(10); + }); + + test("the cylinder is a bindable shape (arrows can attach to it)", () => { + const { shape } = drawCylinder({ x: 0, y: 0 }, { x: 120, y: 120 }); + expect(isBindable(shape)).toBe(true); + }); +}); diff --git a/src/Handlers/ToolsHandler/tools/index.js b/src/Handlers/ToolsHandler/tools/index.js index 200dc75..32db75e 100644 --- a/src/Handlers/ToolsHandler/tools/index.js +++ b/src/Handlers/ToolsHandler/tools/index.js @@ -3,6 +3,7 @@ export { Circle } from "./circle"; export { Font } from "./font"; export { Diamond } from "./diamond"; export { Polygon } from "./polygon"; +export { Cylinder } from "./cylinder"; export { Arrow } from "./arrow"; export { Line } from "./line"; export { Eraser } from "./eraser"; diff --git a/src/components/CanvasEditor/PropertiesPanel/PropertiesPanel.jsx b/src/components/CanvasEditor/PropertiesPanel/PropertiesPanel.jsx index 48d9756..bd18bef 100644 --- a/src/components/CanvasEditor/PropertiesPanel/PropertiesPanel.jsx +++ b/src/components/CanvasEditor/PropertiesPanel/PropertiesPanel.jsx @@ -46,6 +46,7 @@ const STYLEABLE_TOOLS = new Set([ TOOL_CONSTANTS.CIRCLE, TOOL_CONSTANTS.DIAMOND, TOOL_CONSTANTS.POLYGON, + TOOL_CONSTANTS.CYLINDER, TOOL_CONSTANTS.LINE, TOOL_CONSTANTS.ARROW, TOOL_CONSTANTS.FONT, diff --git a/src/constants/IconTools.js b/src/constants/IconTools.js index 3522b67..681996c 100644 --- a/src/constants/IconTools.js +++ b/src/constants/IconTools.js @@ -5,6 +5,7 @@ import { Circle, Diamond, Hexagon, + Cylinder, Minus, ArrowRight, Type, @@ -22,6 +23,7 @@ export const iconToolsMaps = [ { icon: Circle, title: "Circle", id: TOOL_CONSTANTS.CIRCLE }, { icon: Diamond, title: "Diamond", id: TOOL_CONSTANTS.DIAMOND }, { icon: Hexagon, title: "Polygon", id: TOOL_CONSTANTS.POLYGON }, + { icon: Cylinder, title: "Cylinder", id: TOOL_CONSTANTS.CYLINDER }, { icon: Minus, title: "Line", id: TOOL_CONSTANTS.LINE }, { icon: ArrowRight, title: "Arrow", id: TOOL_CONSTANTS.ARROW }, { icon: Type, title: "Text", id: TOOL_CONSTANTS.FONT }, diff --git a/src/constants/tools.js b/src/constants/tools.js index 2960ced..db10376 100644 --- a/src/constants/tools.js +++ b/src/constants/tools.js @@ -10,6 +10,7 @@ export const TOOL_CONSTANTS = { BACKGROUND_COLOR: "bgColors", DIAMOND: "diamond", POLYGON: "polygon", + CYLINDER: "cylinder", ERASER: "eraser", ICONS: "icons", }; @@ -59,6 +60,10 @@ export const TOOL_FUNCTIONS = { createOnClick: true, onMove: true, }, + [TOOL_CONSTANTS.CYLINDER]: { + createOnClick: true, + onMove: true, + }, [TOOL_CONSTANTS.ERASER]: { createOnClick: true, onMove: true, From 2982288dd6b4f5dd17d2cafbceba71f570549059 Mon Sep 17 00:00:00 2001 From: Amark19 Date: Mon, 24 Aug 2026 20:43:45 +0530 Subject: [PATCH 2/2] fix: cylinder fills correctly (bezier rims, not SVG arcs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The arc-based path had wrong sweep directions so a filled cylinder pinched at the sides and left the bottom crescent unfilled (only showed with a fill — the first pass was tested transparent). Rebuild the rims from cubic beziers: straight sides, back top rim (up), front bottom rim (down); fill covers the whole body incl. the top ellipse band, with the front lid rim stroked on top. Co-Authored-By: Claude Opus 4.8 --- src/Handlers/ToolsHandler/tools/cylinder.js | 30 +++++++++++++++------ 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/src/Handlers/ToolsHandler/tools/cylinder.js b/src/Handlers/ToolsHandler/tools/cylinder.js index 0c775d9..5a25e4e 100644 --- a/src/Handlers/ToolsHandler/tools/cylinder.js +++ b/src/Handlers/ToolsHandler/tools/cylinder.js @@ -6,17 +6,31 @@ import { resolveToolStyle } from "../toolStyle"; const DEFAULT_W = 80; const DEFAULT_H = 100; -// SVG path for a database cylinder in a w x h box (top-left origin). The cap -// ellipse stays a shallow lid across aspect ratios. `body` is the closed -// silhouette (back top rim -> right -> bottom front curve); `lid` is the front -// rim arc, stroked on top so the lid reads. +// SVG path for a database cylinder in a w x h box (top-left origin). Built from +// cubic beziers (not SVG arcs) so the rim curve directions are unambiguous and +// fabric fills it cleanly. `body` is the closed silhouette — straight sides, a +// back top rim (bulges up) and a front bottom rim (bulges down), so its fill +// covers the whole cylinder incl. the top ellipse band. `lid` is the top front +// rim, stroked on top so the lid line reads. k = ellipse bezier constant. const cylinderPath = (w, h) => { const rx = w / 2; const ry = Math.max(1, Math.min(h * 0.16, w * 0.35, h / 2 - 0.5)); - const top = ry; - const bottom = h - ry; - const body = `M 0 ${top} A ${rx} ${ry} 0 0 1 ${w} ${top} L ${w} ${bottom} A ${rx} ${ry} 0 0 0 0 ${bottom} Z`; - const lid = `M 0 ${top} A ${rx} ${ry} 0 0 0 ${w} ${top}`; + const k = 0.5523; + const kx = rx * k; + const ky = ry * k; + const topC = ry; // centre line of the top ellipse + const botC = h - ry; // centre line of the bottom ellipse + const body = + `M 0 ${topC} L 0 ${botC} ` + + `C 0 ${botC + ky} ${rx - kx} ${botC + ry} ${rx} ${botC + ry} ` + + `C ${rx + kx} ${botC + ry} ${w} ${botC + ky} ${w} ${botC} ` + + `L ${w} ${topC} ` + + `C ${w} ${topC - ky} ${rx + kx} ${topC - ry} ${rx} ${topC - ry} ` + + `C ${rx - kx} ${topC - ry} 0 ${topC - ky} 0 ${topC} Z`; + const lid = + `M 0 ${topC} ` + + `C 0 ${topC + ky} ${rx - kx} ${topC + ry} ${rx} ${topC + ry} ` + + `C ${rx + kx} ${topC + ry} ${w} ${topC + ky} ${w} ${topC}`; return `${body} ${lid}`; };