import { z } from "zod" import type { WidgetGenerator } from "@/widgets/types" import { CanvasImpl } from "@/widgets/utils/canvas-impl" import { PADDING } from "@/widgets/utils/constants" import { Path2D } from "@/widgets/utils/path-builder" import { createHeightSchema, createWidthSchema } from "@/widgets/utils/schemas" import { estimateWrappedTextDimensions } from "@/widgets/utils/text" import { theme } from "@/widgets/utils/theme" // Factory functions for label schemas to avoid $ref in OpenAI JSON Schema const createRadiusLabelSchema = () => z .string() .nullable() .describe( "Nullable radius label text (e.g., 'r = 5', '10 cm', 'd = 10'). Can include units, equations, or simple values." ) const createHeightLabelSchema = () => z .string() .nullable() .describe( "Nullable height label text (e.g., 'h = 8', '6 cm'). Can include units, equations, or simple values." ) const Cylinder = z .object({ type: z .literal("cylinder") .describe("Specifies a circular cylinder shape."), radiusLabel: createRadiusLabelSchema(), heightLabel: createHeightLabelSchema() }) .strict() const Cone = z .object({ type: z.literal("cone").describe("Specifies a circular cone shape."), radiusLabel: createRadiusLabelSchema(), heightLabel: createHeightLabelSchema() }) .strict() const Sphere = z .object({ type: z.literal("sphere").describe("Specifies a perfect sphere shape."), radiusLabel: createRadiusLabelSchema() }) .strict() export const GeometricSolidDiagramPropsSchema = z .object({ type: z .literal("geometricSolidDiagram") .describe( "Identifies this as a geometric solid diagram showing 3D shapes with dimension labels." ), width: createWidthSchema(), height: createHeightSchema(), shape: z .discriminatedUnion("type", [Cylinder, Cone, Sphere]) .describe("The 3D geometric solid to display with its dimension labels.") }) .strict() .describe( "Creates 3D geometric solids (cylinder, cone, sphere) with optional dimension labels. Uses isometric-style projection to show depth. Essential for teaching volume, surface area, and 3D geometry concepts. Labels help identify key measurements for calculations." ) export type GeometricSolidDiagramProps = z.infer< typeof GeometricSolidDiagramPropsSchema > /** * Generates a 3D diagram of a geometric solid with curved surfaces (e.g., cylinder, cone). * Supports dimension labels for volume and surface area problems. */ export const generateGeometricSolidDiagram: WidgetGenerator< typeof GeometricSolidDiagramPropsSchema > = async (data) => { const { width, height, shape } = data // --- NEW SCALING AND DRAWING LOGIC --- const labelSpace = 40 // Extra space for external labels if needed const canvas = new CanvasImpl({ chartArea: { left: 0, top: 0, width, height }, fontPxDefault: 12, lineHeightDefault: 1.2 }) canvas.addDef( `` ) const availableWidth = width - 2 * PADDING - labelSpace const availableHeight = height - 2 * PADDING - labelSpace if (shape.type === "cylinder") { const radius = 5 // default radius const shapeHeight = 6 // default height let scale = Math.min( availableWidth / (radius * 2), availableHeight / shapeHeight ) let r = radius * scale let h = shapeHeight * scale let ry = r * 0.25 // Ellipse perspective ratio // Auto-scale boost if rendered content is too small relative to available area { const contentWidth = 2 * r const contentHeight = h + ry const availableMin = Math.max( 1, Math.min(availableWidth, availableHeight) ) const occupancy = Math.min(contentWidth, contentHeight) / availableMin const targetOccupancy = 0.7 if (occupancy > 0 && occupancy < targetOccupancy) { const boost = targetOccupancy / occupancy scale *= boost r = radius * scale h = shapeHeight * scale ry = r * 0.25 } } const cx = width / 2 const topY = (height - h) / 2 const bottomY = topY + h // Canvas automatically tracks extents // Side lines canvas.drawLine(cx - r, topY, cx - r, bottomY, { stroke: theme.colors.black, strokeWidth: theme.stroke.width.thick }) canvas.drawLine(cx + r, topY, cx + r, bottomY, { stroke: theme.colors.black, strokeWidth: theme.stroke.width.thick }) // Bottom base (draw back dashed part first, then front solid part) const bottomBackPath = new Path2D() .moveTo(cx - r, bottomY) .arcTo(r, ry, 0, 0, 0, cx + r, bottomY) canvas.drawPath(bottomBackPath, { stroke: theme.colors.black, strokeWidth: theme.stroke.width.thick, dash: theme.stroke.dasharray.dashed }) const bottomFrontPath = new Path2D() .moveTo(cx - r, bottomY) .arcTo(r, ry, 0, 0, 1, cx + r, bottomY) canvas.drawPath(bottomFrontPath, { fill: "rgba(200, 200, 200, 0.2)", stroke: theme.colors.black, strokeWidth: theme.stroke.width.thick }) // Top base canvas.drawEllipse(cx, topY, r, ry, { fill: "rgba(220, 220, 220, 0.4)", stroke: theme.colors.black, strokeWidth: theme.stroke.width.thick }) if (shape.radiusLabel) { // Dashed line for radius on the bottom base canvas.drawLine(cx, bottomY, cx + r, bottomY, { stroke: theme.colors.black, strokeWidth: theme.stroke.width.base, dash: theme.stroke.dasharray.backEdge }) const textY = Math.min(bottomY + 18, height - 10) // Ensure text stays within bounds canvas.drawText({ x: cx + r / 2, y: textY, text: shape.radiusLabel, anchor: "middle", fontPx: theme.font.size.base }) } if (shape.heightLabel) { // External line with arrows for height - positioned with adequate spacing const lineX = cx + r + 10 // Fixed distance from cylinder edge canvas.drawLine(lineX, topY, lineX, bottomY, { stroke: theme.colors.black, strokeWidth: theme.stroke.width.base, markerStart: "url(#arrow)", markerEnd: "url(#arrow)" }) canvas.drawText({ x: lineX + 10, y: height / 2, text: shape.heightLabel, anchor: "start", fontPx: theme.font.size.base }) } } else if (shape.type === "cone") { const radius = 4 // default radius const shapeHeight = 6 // default height let scale = Math.min( availableWidth / (radius * 2), availableHeight / shapeHeight ) let r = radius * scale let h = shapeHeight * scale let ry = r * 0.25 // Ellipse perspective ratio // Auto-scale boost if rendered content is too small relative to available area { const contentWidth = 2 * r const contentHeight = h + ry const availableMin = Math.max( 1, Math.min(availableWidth, availableHeight) ) const occupancy = Math.min(contentWidth, contentHeight) / availableMin const targetOccupancy = 0.7 if (occupancy > 0 && occupancy < targetOccupancy) { const boost = targetOccupancy / occupancy scale *= boost r = radius * scale h = shapeHeight * scale ry = r * 0.25 } } const cx = width / 2 const apexY = (height - h) / 2 const baseY = apexY + h // Canvas automatically tracks extents // Generator lines canvas.drawLine(cx - r, baseY, cx, apexY, { stroke: theme.colors.black, strokeWidth: theme.stroke.width.thick }) canvas.drawLine(cx + r, baseY, cx, apexY, { stroke: theme.colors.black, strokeWidth: theme.stroke.width.thick }) // Base (draw back dashed part first, then front solid part) const baseBackPath = new Path2D() .moveTo(cx - r, baseY) .arcTo(r, ry, 0, 0, 0, cx + r, baseY) canvas.drawPath(baseBackPath, { stroke: theme.colors.black, strokeWidth: theme.stroke.width.thick, dash: theme.stroke.dasharray.dashed }) const baseFrontPath = new Path2D() .moveTo(cx - r, baseY) .arcTo(r, ry, 0, 0, 1, cx + r, baseY) canvas.drawPath(baseFrontPath, { fill: "rgba(200, 200, 200, 0.2)", stroke: theme.colors.black, strokeWidth: theme.stroke.width.thick }) if (shape.radiusLabel) { // Dashed line from center to right for radius canvas.drawLine(cx, baseY, cx + r, baseY, { stroke: theme.colors.black, strokeWidth: theme.stroke.width.base, dash: theme.stroke.dasharray.backEdge }) const textY = Math.min(baseY + 18, height - 10) // Ensure text stays within bounds canvas.drawText({ x: cx + r / 2, y: textY, text: shape.radiusLabel, anchor: "middle", fontPx: theme.font.size.base }) } if (shape.heightLabel) { // Dashed line from apex to center for height canvas.drawLine(cx, apexY, cx, baseY, { stroke: theme.colors.black, strokeWidth: theme.stroke.width.base, dash: theme.stroke.dasharray.backEdge }) // Right angle indicator const indicatorSize = Math.min(10, r * 0.2) const indicatorPath = new Path2D() .moveTo(cx + indicatorSize, baseY) .lineTo(cx + indicatorSize, baseY - indicatorSize) .lineTo(cx, baseY - indicatorSize) canvas.drawPath(indicatorPath, { stroke: theme.colors.black, strokeWidth: theme.stroke.width.thin }) canvas.drawText({ x: cx - 10, y: height / 2, text: shape.heightLabel, anchor: "end", fontPx: theme.font.size.base }) } } else if (shape.type === "sphere") { const radius = 5 // default radius let scale = Math.min( availableWidth / (radius * 2), availableHeight / (radius * 2) ) let r = radius * scale let ry = r * 0.3 // Ellipse perspective ratio for equator // Auto-scale boost if rendered content is too small relative to available area { const contentWidth = 2 * r const contentHeight = 2 * r const availableMin = Math.max( 1, Math.min(availableWidth, availableHeight) ) const occupancy = Math.min(contentWidth, contentHeight) / availableMin const targetOccupancy = 0.7 if (occupancy > 0 && occupancy < targetOccupancy) { const boost = targetOccupancy / occupancy scale *= boost r = radius * scale ry = r * 0.3 } } const cx = width / 2 const cy = height / 2 // Canvas automatically tracks extents // Main sphere outline canvas.drawEllipse(cx, cy, r, r, { fill: "rgba(220, 220, 220, 0.4)", stroke: theme.colors.black, strokeWidth: theme.stroke.width.thick }) // Internal equator for 3D effect (dashed back, solid front) const equatorBackPath = new Path2D() .moveTo(cx - r, cy) .arcTo(r, ry, 0, 0, 0, cx + r, cy) canvas.drawPath(equatorBackPath, { stroke: theme.colors.black, strokeWidth: theme.stroke.width.base, dash: theme.stroke.dasharray.dashed }) const equatorFrontPath = new Path2D() .moveTo(cx - r, cy) .arcTo(r, ry, 0, 0, 1, cx + r, cy) canvas.drawPath(equatorFrontPath, { stroke: theme.colors.black, strokeWidth: theme.stroke.width.base }) if (shape.radiusLabel) { // Dashed line from center to circumference for radius canvas.drawLine(cx, cy, cx + r, cy, { stroke: theme.colors.black, strokeWidth: theme.stroke.width.base, dash: theme.stroke.dasharray.backEdge }) // Place label centered above the dashed radius with a minimal non-overlapping gap const fontPx = theme.font.size.base const dims = estimateWrappedTextDimensions( shape.radiusLabel, Number.POSITIVE_INFINITY, fontPx, 1.2 ) const minGap = theme.stroke.width.base + 2 const labelX = cx + r / 2 const labelY = cy - (dims.height / 2 + minGap) canvas.drawText({ x: labelX, y: labelY, text: shape.radiusLabel, anchor: "middle", dominantBaseline: "middle", fontPx }) } } // NEW: Finalize the canvas and construct the root SVG element const { svgBody, vbMinX, vbMinY, width: finalWidth, height: finalHeight } = canvas.finalize(PADDING) return `${svgBody}` }