diff --git a/client/scripts/rtl-disable-baseline.json b/client/scripts/rtl-disable-baseline.json index 48a57d84e..fa750f645 100644 --- a/client/scripts/rtl-disable-baseline.json +++ b/client/scripts/rtl-disable-baseline.json @@ -1,4 +1,5 @@ { + "components/Studio/LineHandles.tsx": 1, "components/Studio/StudioSelectionChrome.tsx": 2, "mobile/components/trek3d/MascotClip.tsx": 1, "pages/tripPlanner/PlanMapStage.tsx": 4 diff --git a/client/src/components/Assistant/cards/book/bookCardModel.ts b/client/src/components/Assistant/cards/book/bookCardModel.ts index 1fa2d3d83..949e06a18 100644 --- a/client/src/components/Assistant/cards/book/bookCardModel.ts +++ b/client/src/components/Assistant/cards/book/bookCardModel.ts @@ -58,6 +58,8 @@ const FINDING_LOOK: Readonly> = { palette_drift: { labelKey: 'assistant.card.book.finding.palette_drift' }, repeat_layout: { labelKey: 'assistant.card.book.finding.repeat_layout' }, raster_effect: { labelKey: 'assistant.card.book.finding.raster_effect' }, + hairline: { labelKey: 'assistant.card.book.finding.hairline' }, + edge_unused: { labelKey: 'assistant.card.book.finding.edge_unused' }, }; /** A page as the Studio names it: the cover, the first page, double page 3. */ diff --git a/client/src/components/Studio/LineHandles.tsx b/client/src/components/Studio/LineHandles.tsx new file mode 100644 index 000000000..a655ae85b --- /dev/null +++ b/client/src/components/Studio/LineHandles.tsx @@ -0,0 +1,159 @@ +import { type BookLineElement, MAX_LINE_POINTS, bookLinePoints, bookLineWithPoints } from '@trek/shared'; +import { useRef } from 'react'; + +import { useStudioStore } from '../../store/studioStore'; +import { constrainPoint } from './lineSnap'; + +/** + * The canvas handles of a selected line: one on each of its points, to drag + * it where it should go, and a smaller one half way along each segment that + * adds a point when it is pulled. Double-clicking a point between the ends + * takes it out again. + * + * Drawn inside the selection box, which carries the line's turn, at the + * points' own fractions of it, so they sit on the drawing at any zoom and any + * angle. The drag itself is measured on the spread, where the pointer is, and + * the frame is refitted around the moved points by `bookLineWithPoints`, the + * same refit the book engine uses. + * + * While dragging, a segment within a couple of degrees of level or plumb is + * made exactly level or plumb, and Shift holds it to 15 degree steps. + */ + +type Pt = [number, number]; + +interface Drag { + index: number; + start: Pt; + pointerX: number; + pointerY: number; + points: Pt[]; +} + +export function LineHandles({ + el, + spreadIndex, + scaled, + t, +}: { + el: BookLineElement; + spreadIndex: number; + /** Screen pixels per millimetre at the current zoom. */ + scaled: number; + t: (k: string) => string; +}) { + const update = useStudioStore((s) => s.updateElement); + const beginGesture = useStudioStore((s) => s.beginGesture); + const endGesture = useStudioStore((s) => s.endGesture); + const commit = useStudioStore((s) => s.commit); + const drag = useRef(null); + + const place = (points: Pt[]) => { + const next = bookLineWithPoints(el, points); + update(spreadIndex, el.id, { frame: next.frame, points: next.points }); + }; + + const grab = (e: React.PointerEvent, index: number, points: Pt[]) => { + e.stopPropagation(); + e.preventDefault(); + (e.target as Element).setPointerCapture?.(e.pointerId); + beginGesture(); + drag.current = { index, start: points[index], pointerX: e.clientX, pointerY: e.clientY, points }; + }; + + const onMove = (e: React.PointerEvent) => { + const d = drag.current; + if (!d) return; + e.stopPropagation(); + const raw: Pt = [d.start[0] + (e.clientX - d.pointerX) / scaled, d.start[1] + (e.clientY - d.pointerY) / scaled]; + const anchor = d.points[d.index === 0 ? 1 : d.index - 1]; + const points = d.points.slice(); + points[d.index] = constrainPoint(raw, anchor, e.shiftKey); + place(points); + }; + + const onUp = (e: React.PointerEvent) => { + if (!drag.current) return; + e.stopPropagation(); + drag.current = null; + endGesture(); + }; + + /** Pull a new point out of the middle of segment `i`. */ + const insert = (e: React.PointerEvent, i: number) => { + const world = bookLinePoints(el) as Pt[]; + const a = world[i]; + const b = world[i + 1]; + const points = [...world.slice(0, i + 1), [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2] as Pt, ...world.slice(i + 1)]; + grab(e, i + 1, points); + place(points); + }; + + const remove = (i: number) => { + const world = bookLinePoints(el) as Pt[]; + if (world.length <= 2) return; + const next = bookLineWithPoints( + el, + world.filter((_, k) => k !== i) + ); + commit((d) => ({ + ...d, + spreads: d.spreads.map((sp, k) => + k !== spreadIndex + ? sp + : { + ...sp, + elements: sp.elements.map((x) => + x.kind === 'line' && x.id === el.id ? { ...x, frame: next.frame, points: next.points } : x + ), + } + ), + })); + }; + + // A point on the drawing, measured from the frame, not a side of the text. + const pct = (p: { x: number; y: number }) => ({ left: `${p.x * 100}%`, top: `${p.y * 100}%` }); // rtl-lint-disable + const canAdd = el.points.length < MAX_LINE_POINTS; + + return ( + <> + {canAdd && + el.points.slice(1).map((p, i) => { + const a = el.points[i]; + return ( + insert(e, i)} + onPointerMove={onMove} + onPointerUp={onUp} + onPointerCancel={onUp} + /> + ); + })} + {el.points.map((p, i) => ( + 0 && i < el.points.length - 1 ? t('journey.studio.removePoint') : undefined} + onPointerDown={(e) => grab(e, i, bookLinePoints(el) as Pt[])} + onPointerMove={onMove} + onPointerUp={onUp} + onPointerCancel={onUp} + onDoubleClick={(e) => { + e.stopPropagation(); + if (i > 0 && i < el.points.length - 1) remove(i); + }} + /> + ))} + + ); +} diff --git a/client/src/components/Studio/LineView.tsx b/client/src/components/Studio/LineView.tsx new file mode 100644 index 000000000..6a3ed886b --- /dev/null +++ b/client/src/components/Studio/LineView.tsx @@ -0,0 +1,46 @@ +import type { BookLineElement } from '@trek/shared'; +import type { CSSProperties } from 'react'; +import { useMemo } from 'react'; + +import { type LineGeometry, lineGeometry } from './lineRender'; + +/** The paths of a line's geometry in one colour: the body stroked, the heads and the ornament filled. */ +export function LinePaths({ g, color, width }: { g: LineGeometry; color: string; width: number }) { + const stroke = { fill: 'none', stroke: color, strokeWidth: width, strokeLinejoin: 'round' as const }; + return ( + <> + {g.body.map((d, i) => ( + + ))} + {g.strokes.map((d, i) => ( + + ))} + {g.fills.map((d, i) => ( + + ))} + + ); +} + +/** + * A line element, drawn: its body stroked, its heads and ornament filled, all + * in the millimetres of its own frame (one SVG unit is one millimetre, as for + * the shapes). Overflow stays visible so a head on a thin frame is not cut. + */ +export function LineView({ el, frameStyle }: { el: BookLineElement; frameStyle: CSSProperties }) { + const { w, h } = el.frame; + const g = useMemo(() => lineGeometry(el, w, h, el.id), [el, w, h]); + return ( +
+ + + +
+ ); +} diff --git a/client/src/components/Studio/SpreadView.tsx b/client/src/components/Studio/SpreadView.tsx index 4733ee48f..ee0155bed 100644 --- a/client/src/components/Studio/SpreadView.tsx +++ b/client/src/components/Studio/SpreadView.tsx @@ -5,7 +5,9 @@ import type { import { fontStack, nearestWeight } from './bookFonts' import { iconComponent } from './iconLibrary' import { PhotoView } from './PhotoView' -import { HOLED_SHAPES, SHAPE_PATHS, scalePath } from './shapes' +import { HOLED_SHAPES } from './shapes' +import { hasEdge, shapeOutline } from './shapeOutline' +import { LineView } from './LineView' import { TravelElementView } from './TravelElements' import { PageNumbers } from './PageNumbers' import { PaintedShapeView } from './paint/PaintedShape' @@ -64,7 +66,7 @@ function ShapeView({ el }: { el: BookShapeElement }) { const gradientId = usePaintId(`g-${el.id}`) const gradient = el.gradient !== 'none' && el.fill - if (el.shape === 'rect' || el.shape === 'ellipse') { + if ((el.shape === 'rect' || el.shape === 'ellipse') && !hasEdge(el, el.shape)) { const background = !gradient ? fill : `linear-gradient(${el.gradient === 'up' ? 'to top' : 'to bottom'},` @@ -87,7 +89,7 @@ function ShapeView({ el }: { el: BookShapeElement }) { const sw = el.stroke ? el.strokeWidth : 0 const w = Math.max(0.01, el.frame.w - sw) const h = Math.max(0.01, el.frame.h - sw) - const d = scalePath(SHAPE_PATHS[el.shape], w, h) + const d = shapeOutline(el, w, h) // Proportional to the stroke, so a dashed outline keeps its rhythm whether it // is a hairline on a caption rule or a 2mm band around a cover panel. @@ -177,6 +179,7 @@ export function ElementView({ // the travel elements and draws null for anything they do not know. if (el.kind === 'icon') return if (el.kind === 'sticker') return + if (el.kind === 'line') return if (el.kind !== 'text') { // map, stats, countries, badge — the elements drawn from the journey's own diff --git a/client/src/components/Studio/StudioCanvas.tsx b/client/src/components/Studio/StudioCanvas.tsx index 29b864e72..6ea61b9bc 100644 --- a/client/src/components/Studio/StudioCanvas.tsx +++ b/client/src/components/Studio/StudioCanvas.tsx @@ -6,6 +6,7 @@ import { PeerCursors } from './PeerCursors' import type { PeerCursor } from './useBookPresence' import { bookElementId as elementId } from '@trek/shared' import { fontStack, nearestWeight } from './bookFonts' +import { LineHandles } from './LineHandles' import { useSpreadInteraction } from './useSpreadInteraction' import { useStudioKeys } from './useStudioKeys' import { selectClass, selectionShape } from './studioGestures' @@ -408,7 +409,12 @@ export function StudioCanvas({ ))} - {sel.length === 1 && !dragging && !el.locked && } + {/* A line is shaped by its points rather than its box. */} + {sel.length === 1 && !el.locked && el.kind === 'line' && ( + + )} + + {sel.length === 1 && !dragging && !el.locked && el.kind !== 'line' && } ))} diff --git a/client/src/components/Studio/StudioEdgeControls.tsx b/client/src/components/Studio/StudioEdgeControls.tsx new file mode 100644 index 000000000..ecb2b0dbc --- /dev/null +++ b/client/src/components/Studio/StudioEdgeControls.tsx @@ -0,0 +1,89 @@ +import { + BOOK_EDGES, + BOOK_EDGE_SIZE_MAX, + BOOK_EDGE_SIZE_MIN, + type BookEdge, + type BookShapeId, + bookShapeTakesEdge, +} from '@trek/shared'; + +import { NumField, Section } from './StudioControls'; +import { DEFAULT_EDGE_SIZE } from './shapeOutline'; +import { edgedPath } from './shapes-edges'; + +/** + * The edge of a shape or of a photo's cut: a row of tiles, each the edge drawn + * on a little card, and how deep it bites. + * + * Shown for the shapes an edge is cut along and for photos. On an outline that + * takes none (a heart, a star) it stays away, unless an edge is already set + * there, which it then says does nothing and offers to put back. + */ + +const CARD = 'M0 0L40 0L40 30L0 30Z'; + +/** One edge, drawn on a card the size of a tile. Seeded alike, so the torn tile does not change while you look at it. */ +function EdgeGlyph({ edge }: { edge: BookEdge }) { + return ( + + + + ); +} + +export function EdgeSection({ + edge, + edgeSize, + outline, + onSet, + t, +}: { + edge: BookEdge | undefined; + edgeSize: number | undefined; + /** The shape the edge would be cut along; null for a photo's plain rectangle. */ + outline: BookShapeId | null; + onSet: (patch: { edge?: BookEdge; edgeSize?: number }) => void; + t: (k: string) => string; +}) { + const current = edge ?? 'straight'; + const takes = bookShapeTakesEdge(outline); + // A heart or a star keeps its drawn line, so there is nothing to offer unless an edge is already set on one. + if (!takes && current === 'straight') return null; + return ( +
+ {!takes && ( +

+ {t('journey.studio.edgeUnavailable')} +

+ )} +
+ {BOOK_EDGES.map((e) => ( + + ))} +
+ {takes && current !== 'straight' && ( +
+ onSet({ edgeSize: v })} + /> +
+ )} +
+ ); +} diff --git a/client/src/components/Studio/StudioElementsPanel.tsx b/client/src/components/Studio/StudioElementsPanel.tsx index 38882fbe8..56dbebf56 100644 --- a/client/src/components/Studio/StudioElementsPanel.tsx +++ b/client/src/components/Studio/StudioElementsPanel.tsx @@ -2,8 +2,10 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { Minus, Quote, Search, Square, X } from 'lucide-react' import type { BookElement, BookPageSetup, BookShapeId } from '@trek/shared' import { useStudioStore } from '../../store/studioStore' -import { FRAME_SHAPES, HOLED_SHAPES, SHAPE_GROUPS, SHAPE_PATHS } from './shapes' -import { BOOK_GRIDS as GRIDS, bookElementId, bookGridElements as gridElements, defaultBookGridBox as defaultGridBox } from '@trek/shared' +import { FRAME_SHAPES, HOLED_SHAPES, SHAPE_GROUPS } from './shapes' +import { BOOK_GRIDS as GRIDS, bookElementId, bookShapeSize, bookGridElements as gridElements, defaultBookGridBox as defaultGridBox } from '@trek/shared' +import { shapeTile } from './shapeOutline' +import { StudioLineShelf } from './StudioLineShelf' import { PanelHead } from './StudioPanelHead' import { FEATURED_ICONS, iconComponent, iconLabel, searchIcons } from './iconLibrary' import '../../styles/studio-elements.css' @@ -35,8 +37,9 @@ type FrameStyle = 'none' | 'polaroid' | 'white' | 'shadow' | 'film' | 'tape' */ export function ShapeGlyph({ shape, photo = false }: { shape: BookShapeId; photo?: boolean }) { const gid = `sg-${photo ? 'p' : 's'}-${shape}` + const tile = shapeTile(shape) return ( - + {photo && ( {/* theme-lint-disable — a frame preview stands in for a photograph, not for UI chrome */} @@ -49,7 +52,7 @@ export function ShapeGlyph({ shape, photo = false }: { shape: BookShapeId; photo )} @@ -145,10 +148,12 @@ export function StudioElementsPanel({ page, t }: { page: BookPageSetup; t: (k: s || shape === 'capsule' const tall = shape === 'pin' || shape === 'drop' || shape === 'arrow-up' || shape === 'arrow-down' const isRule = shape === 'line' + const own = bookShapeSize(shape, side) addElement(active, { id: uid('s'), kind: 'shape', frame: isRule ? centre(page.pageWidth * 0.5, 0.5) + : own ? centre(own.w, own.h) : centre(wide ? side * 1.7 : side, tall ? side * 1.35 : wide ? side * 0.55 : side), rotation: 0, opacity: 1, locked: false, // A rule is a shape too — a hairline box rather than its own element type, @@ -283,6 +288,8 @@ export function StudioElementsPanel({ page, t }: { page: BookPageSetup; t: (k: s + addElement(active, el)} t={t} /> + {SHAPE_GROUPS.map(group => (
{t(`journey.studio.shapeGroup.${group.id}`)}
diff --git a/client/src/components/Studio/StudioInspector.tsx b/client/src/components/Studio/StudioInspector.tsx index 21513281b..c02cae665 100644 --- a/client/src/components/Studio/StudioInspector.tsx +++ b/client/src/components/Studio/StudioInspector.tsx @@ -24,6 +24,8 @@ import type { JourneySource } from './StudioSidebar' import { Tooltip } from '../shared/Tooltip' import { StudioArrangeSection } from './StudioArrangeSection' import { selectionTitle } from './studioLayerRows' +import { EdgeSection } from './StudioEdgeControls' +import { LineInspector } from './StudioLineInspector' /** How many icons the swap grid offers at once. A search narrows it below this. */ const ICON_CHOICES = 60 @@ -492,6 +494,14 @@ export function StudioInspector({ ))}
+ + set(patch)} + t={t} + /> )} @@ -536,6 +546,8 @@ export function StudioInspector({ t={t} /> + set(patch)} t={t} /> +
} + {el.kind === 'line' && } diff --git a/client/src/components/Studio/StudioLineInspector.tsx b/client/src/components/Studio/StudioLineInspector.tsx new file mode 100644 index 000000000..b49b9d7be --- /dev/null +++ b/client/src/components/Studio/StudioLineInspector.tsx @@ -0,0 +1,214 @@ +import { + BOOK_LINE_CAPS, + BOOK_LINE_ORNAMENTS, + type BookLineCap, + type BookLineElement, + type BookLineOrnament, + bookLineStyled, +} from '@trek/shared'; +import { ArrowLeftRight } from 'lucide-react'; + +import { useStudioStore } from '../../store/studioStore'; +import { LinePaths } from './LineView'; +import { Choice, Line, NumField, Section } from './StudioControls'; +import { Swatches } from './StudioSwatches'; +import { lineGeometry } from './lineRender'; + +/** + * The inspector of a drawn line: its colour and weight, dashes, the course it + * takes, a wave or a zigzag, the hand-drawn wobble, what sits on each end and + * in the middle. + * + * Every change goes through `bookLineStyled`, the book engine's own restyle, + * so a wider stroke or a new head refits the frame exactly as the assistant's + * `style` does, and the ends stay where they were drawn. + */ + +type Look = Partial>; + +type GlyphLook = Parameters[0]; + +/** A tiny level line for the pickers: a plain rule, with what the picker is choosing set on it. */ +function MiniLine({ over }: { over: Partial }) { + const el: GlyphLook = { + points: [ + { x: 0.08, y: 0.5 }, + { x: 0.92, y: 0.5 }, + ], + curve: 'straight', + strokeWidth: 1.4, + dash: 'solid', + squiggle: 'none', + startCap: 'none', + endCap: 'none', + wobble: 0, + ornament: 'none', + ...over, + }; + const g = lineGeometry(el, 28, 12, 'glyph'); + return ( + + + + ); +} + +/** A tiny line with this end on it, for the end pickers. */ +const CapGlyph = ({ cap, start }: { cap: BookLineCap; start: boolean }) => ( + +); + +/** A tiny rule with this ornament in its middle. */ +const OrnamentGlyph = ({ ornament }: { ornament: BookLineOrnament }) => ( + +); + +export function LineInspector({ + el, + spreadIndex, + t, +}: { + el: BookLineElement; + spreadIndex: number; + t: (k: string) => string; +}) { + const commit = useStudioStore((s) => s.commit); + const selection = useStudioStore((s) => s.selection); + + /** Restyle every selected line, each refitted around its own points. */ + const set = (look: Look) => + commit((d) => ({ + ...d, + spreads: d.spreads.map((sp, i) => + i !== spreadIndex + ? sp + : { + ...sp, + elements: sp.elements.map((e) => + e.kind === 'line' && selection.includes(e.id) && !e.locked ? bookLineStyled(e, look) : e + ), + } + ), + })); + + /** The line turned round: its points in the other order and its two ends swapped with them. */ + const swapEnds = () => + commit((d) => ({ + ...d, + spreads: d.spreads.map((sp, i) => + i !== spreadIndex + ? sp + : { + ...sp, + elements: sp.elements.map((e) => + e.kind === 'line' && selection.includes(e.id) && !e.locked + ? { ...e, points: [...e.points].reverse(), startCap: e.endCap, endCap: e.startCap } + : e + ), + } + ), + })); + + const caps = (start: boolean) => + BOOK_LINE_CAPS.map((c) => ({ + value: c, + label: , + title: t(`journey.studio.cap.${c}`), + })); + + return ( + <> +
+ + set({ stroke: c })} /> + +
+ set({ strokeWidth: v })} + /> + set({ wobble: Math.min(1, Math.max(0, v / 100)) })} + /> +
+
+ + ({ value, label: t(`journey.studio.${key}`) }))} + onPick={(dash) => set({ dash })} + /> + + + ({ + value, + label: t(`journey.studio.lineCourse.${value}`), + }))} + onPick={(curve) => set({ curve })} + /> + + + ({ + value, + label: t(`journey.studio.lineBody.${value}`), + }))} + onPick={(squiggle) => set({ squiggle })} + /> + +
+

+ {t('journey.studio.linePointsHint')} +

+
+ +
+ + set({ startCap })} /> + + + set({ endCap })} /> + +
+ +
+
+ +
+ ({ + value: o, + label: , + title: t(`journey.studio.ornament.${o}`), + }))} + onPick={(ornament) => set({ ornament })} + ariaLabel={t('journey.studio.ornamentTitle')} + /> +
+ + ); +} diff --git a/client/src/components/Studio/StudioLineShelf.tsx b/client/src/components/Studio/StudioLineShelf.tsx new file mode 100644 index 000000000..2734291b9 --- /dev/null +++ b/client/src/components/Studio/StudioLineShelf.tsx @@ -0,0 +1,86 @@ +import { type BookElement, type BookPageSetup, bookElementId, bookNewLine } from '@trek/shared'; + +import { LinePaths } from './LineView'; +import { DIVIDER_PRESETS, LINE_PRESETS, type LinePreset as Preset, presetPoints } from './linePresets'; +import { lineGeometry } from './lineRender'; + +/** + * The drawn lines and dividers of the Elements panel: a straight rule, arrows, + * dashes and dots, a curve, a wave, a zigzag, a line drawn by hand, a dotted + * route, and the rules a title sits between. + * + * Each tile is drawn by the same geometry the page uses, so the tile is the + * line you get. They arrive half a page wide on the right-hand page, where the + * assistant's `add` puts them too, and their ends are moved on the canvas. + */ + +/** A line as the page draws it, as a tile. */ +function LineGlyph({ preset }: { preset: Preset }) { + const W = 40; + const H = 24; + const el = bookNewLine( + { + ...preset.look, + strokeWidth: Math.max(1.2, (preset.look.strokeWidth ?? 0.5) * 2.4), + points: presetPoints(preset, 4, H / 2, W - 8), + }, + { id: preset.id, frame: null, inner: false, page: { pageWidth: W, pageHeight: H } as BookPageSetup } + ); + const g = lineGeometry(el, el.frame.w, el.frame.h, preset.id); + return ( + + + + ); +} + +export function StudioLineShelf({ + page, + single, + onAdd, + t, +}: { + page: BookPageSetup; + single: boolean; + onAdd: (el: BookElement) => void; + t: (k: string) => string; +}) { + const add = (preset: Preset) => { + const origin = single ? 0 : page.pageWidth; + const w = page.pageWidth * (preset.id.startsWith('divider') ? 0.4 : 0.5); + const x = origin + (page.pageWidth - w) / 2; + const points = presetPoints(preset, x, page.pageHeight / 2, w); + onAdd(bookNewLine({ ...preset.look, points }, { id: bookElementId('l'), frame: null, inner: !single, page })); + }; + const tiles = (list: readonly Preset[]) => ( +
+ {list.map((p) => { + const label = t(`journey.studio.linePreset.${p.id}`); + return ( + + ); + })} +
+ ); + return ( + <> +
+
{t('journey.studio.drawnLines')}
+ {tiles(LINE_PRESETS)} +
+
+
{t('journey.studio.dividers')}
+ {tiles(DIVIDER_PRESETS)} +
+ + ); +} diff --git a/client/src/components/Studio/linePresets.ts b/client/src/components/Studio/linePresets.ts new file mode 100644 index 000000000..95d39e3e3 --- /dev/null +++ b/client/src/components/Studio/linePresets.ts @@ -0,0 +1,55 @@ +import type { BookLineElement } from '@trek/shared'; + +/** + * The lines and dividers the Elements panel offers, as looks for a new line, + * and the points each is placed with. + */ + +type Look = Partial>; + +export interface LinePreset { + id: string; + look: Look; + /** A curve through three points rather than a straight rule. */ + bend?: boolean; +} + +export const LINE_PRESETS: readonly LinePreset[] = [ + { id: 'straight', look: {} }, + { id: 'arrow', look: { endCap: 'arrow' } }, + { id: 'double', look: { startCap: 'arrow', endCap: 'arrow' } }, + { id: 'dashed', look: { dash: 'dashed' } }, + { id: 'dotted', look: { dash: 'dotted', strokeWidth: 0.8 } }, + { id: 'curved', look: { curve: 'smooth', endCap: 'arrow' }, bend: true }, + { id: 'wave', look: { squiggle: 'wave' } }, + { id: 'zigzag', look: { squiggle: 'zigzag' } }, + { id: 'hand', look: { wobble: 0.8, endCap: 'open', strokeWidth: 0.7 } }, + { + id: 'route', + look: { dash: 'dotted', strokeWidth: 0.9, curve: 'smooth', startCap: 'dot', endCap: 'arrow' }, + bend: true, + }, +]; + +export const DIVIDER_PRESETS: readonly LinePreset[] = (['diamond', 'sun', 'star', 'heart', 'dot'] as const).map( + (o) => ({ + id: `divider-${o}`, + look: { ornament: o, strokeWidth: 0.3 }, + }) +); + +/** The points a preset is placed with, in a box `w` wide around a middle line. */ +export function presetPoints(p: LinePreset, x: number, y: number, w: number): { x: number; y: number }[] { + if (!p.bend) { + return [ + { x, y }, + { x: x + w, y }, + ]; + } + const rise = w * 0.18; + return [ + { x, y: y + rise }, + { x: x + w * 0.5, y: y - rise }, + { x: x + w, y: y + rise }, + ]; +} diff --git a/client/src/components/Studio/lineRender.ts b/client/src/components/Studio/lineRender.ts new file mode 100644 index 000000000..fa1a4ed77 --- /dev/null +++ b/client/src/components/Studio/lineRender.ts @@ -0,0 +1,284 @@ +import { type BookLineCap, type BookLineElement, type BookLineOrnament, bookLineCapSize } from '@trek/shared'; + +import { type Pt, seeded, seedOf } from './shapeDraw'; + +/** + * A line element as geometry: the stroked body and the parts drawn on it, in + * the millimetres of its own frame. LineView turns this into SVG; the panel's + * previews draw the same thing at tile size. + * + * Everything is a path, never an SVG ``. A marker is sized in the + * stroke's own units and drawn by rules browsers and print engines have never + * agreed on, while a path is exactly where it says it is, in every one of + * them and in the PDF. + * + * ── The order things happen in ─────────────────────────────────────────── + * + * 1. The points become a centre line: straight between them, or a smooth + * curve through them. + * 2. The body is cut back where a head sits on an end, so a thick line does + * not poke through the point of its own arrow, and opened in the middle + * where an ornament sits. + * 3. A wave or a zigzag is laid along it, then the hand-drawn wobble. + * 4. Heads and the ornament are placed on the clean centre line, so they + * point the way the line goes rather than the way the last wiggle went. + */ + +export interface LineGeometry { + /** The stroked parts of the body: one, or two either side of an ornament. */ + body: string[]; + /** Shapes filled in the line's colour: a filled head, a dot, the ornament. */ + fills: string[]; + /** Strokes drawn at the line's own width beside the body: an open head, a bar. */ + strokes: string[]; + dash: string | undefined; + cap: 'round' | 'butt'; +} + +type LineLook = Pick< + BookLineElement, + 'points' | 'curve' | 'strokeWidth' | 'dash' | 'squiggle' | 'startCap' | 'endCap' | 'wobble' | 'ornament' +>; + +const r2 = (n: number) => Math.round(n * 100) / 100; +const fmt = ([x, y]: Pt) => `${r2(x)} ${r2(y)}`; +const dist = (a: Pt, b: Pt) => Math.hypot(b[0] - a[0], b[1] - a[1]); + +/** A Catmull-Rom curve through the points, sampled about every half millimetre. */ +function smoothThrough(pts: readonly Pt[]): Pt[] { + const out: Pt[] = [pts[0]]; + for (let i = 0; i < pts.length - 1; i++) { + const p0 = pts[Math.max(0, i - 1)]; + const p1 = pts[i]; + const p2 = pts[i + 1]; + const p3 = pts[Math.min(pts.length - 1, i + 2)]; + const n = Math.max(6, Math.min(120, Math.ceil(dist(p1, p2) / 0.5))); + for (let k = 1; k <= n; k++) { + const t = k / n; + const t2 = t * t; + const t3 = t2 * t; + const at = (a: number, b: number, c: number, d: number) => + 0.5 * (2 * b + (-a + c) * t + (2 * a - 5 * b + 4 * c - d) * t2 + (-a + 3 * b - 3 * c + d) * t3); + out.push([at(p0[0], p1[0], p2[0], p3[0]), at(p0[1], p1[1], p2[1], p3[1])]); + } + } + return out; +} + +/** Cumulative lengths along a polyline. */ +function lengths(pts: readonly Pt[]): number[] { + const out = [0]; + for (let i = 1; i < pts.length; i++) out.push(out[i - 1] + dist(pts[i - 1], pts[i])); + return out; +} + +/** The point and the direction `s` millimetres along a polyline. */ +function along(pts: readonly Pt[], acc: readonly number[], s: number): { p: Pt; dir: Pt } { + let i = 1; + while (i < pts.length - 1 && acc[i] < s) i++; + const a = pts[i - 1]; + const b = pts[i]; + const seg = acc[i] - acc[i - 1] || 1; + const t = Math.min(1, Math.max(0, (s - acc[i - 1]) / seg)); + return { p: [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t], dir: [(b[0] - a[0]) / seg, (b[1] - a[1]) / seg] }; +} + +/** The part of a polyline between two lengths along it, resampled no coarser than `step`. */ +function slice(pts: readonly Pt[], acc: readonly number[], from: number, to: number, step: number): Pt[] { + if (to - from <= 0.01) return []; + const n = Math.max(1, Math.ceil((to - from) / step)); + const out: Pt[] = []; + // Every original corner inside the slice stays, or a zigzag of points loses its corners. + const marks = new Set(); + for (let k = 0; k <= n; k++) marks.add(from + ((to - from) * k) / n); + for (const s of acc) if (s > from && s < to) marks.add(s); + for (const s of [...marks].sort((a, b) => a - b)) out.push(along(pts, acc, s).p); + return out; +} + +/** Lay a wave or a zigzag along a centre line. */ +function squiggled(pts: Pt[], kind: LineLook['squiggle'], width: number): Pt[] { + if (kind === 'none' || pts.length < 2) return pts; + const acc = lengths(pts); + const total = acc[acc.length - 1]; + const wave = Math.max(3, width * 8); + const periods = Math.max(1, Math.round(total / wave)); + const lambda = total / periods; + const amp = Math.max(0.8, width * 2.2); + const step = kind === 'zigzag' ? lambda / 4 : Math.min(0.5, lambda / 12); + const out: Pt[] = []; + for (let s = 0; s <= total + 1e-6; s += step) { + const { p, dir } = along(pts, acc, s); + const phase = s / lambda; + const off = kind === 'wave' ? Math.sin(phase * Math.PI * 2) : [0, 1, 0, -1][Math.round(phase * 4) % 4]; + // Calmer over the last half wave at each end, so the line runs straight into a head. + const hold = kind === 'wave' ? Math.min(1, s / (lambda / 2), (total - s) / (lambda / 2)) : 1; + out.push([p[0] - dir[1] * amp * off * hold, p[1] + dir[0] * amp * off * hold]); + } + return out; +} + +/** The tremor of a line drawn by hand: a slow sideways drift, the same every time for the same element. */ +function wobbled(pts: Pt[], amount: number, width: number, seed: number): Pt[] { + if (amount <= 0 || pts.length < 2) return pts; + const rnd = seeded(seed); + const acc = lengths(pts); + const total = acc[acc.length - 1]; + const knots = Array.from({ length: Math.ceil(total / 9) + 3 }, () => rnd() * 2 - 1); + const drift = (s: number) => { + const x = s / 9; + const k = Math.floor(x); + const f = x - k; + const e = f * f * (3 - 2 * f); + return knots[k] * (1 - e) + knots[k + 1] * e; + }; + const amp = amount * (0.5 + width * 1.2); + const dense = slice(pts, acc, 0, total, 0.6); + const dacc = lengths(dense); + return dense.map((p, i) => { + const { dir } = along(dense, dacc, dacc[i]); + // Held still at the very ends, so a hand-drawn arrow still meets its head. + const hold = Math.min(1, dacc[i] / 3, (total - dacc[i]) / 3); + const off = drift(dacc[i]) * amp * hold; + return [p[0] - dir[1] * off, p[1] + dir[0] * off] as Pt; + }); +} + +const path = (pts: readonly Pt[]) => (pts.length < 2 ? '' : `M${pts.map(fmt).join('L')}`); +const closed = (pts: readonly Pt[]) => `${path(pts)}Z`; + +/** A circle as a closed path, four cubics. */ +function disc([cx, cy]: Pt, r: number): string { + const k = r * 0.5523; + return ( + `M${fmt([cx, cy - r])}C${fmt([cx + k, cy - r])} ${fmt([cx + r, cy - k])} ${fmt([cx + r, cy])}` + + `C${fmt([cx + r, cy + k])} ${fmt([cx + k, cy + r])} ${fmt([cx, cy + r])}` + + `C${fmt([cx - k, cy + r])} ${fmt([cx - r, cy + k])} ${fmt([cx - r, cy])}` + + `C${fmt([cx - r, cy - k])} ${fmt([cx - k, cy - r])} ${fmt([cx, cy - r])}Z` + ); +} + +/** A point `a` along the direction and `b` across it from `p`. */ +const at = (p: Pt, dir: Pt, a: number, b: number): Pt => [ + p[0] + dir[0] * a - dir[1] * b, + p[1] + dir[1] * a + dir[0] * b, +]; + +/** How far a head cuts the body back from its end. */ +function capTrim(cap: BookLineCap, size: number, width: number): number { + if (cap === 'arrow') return size * 0.7; + if (cap === 'open') return width * 0.5; + return 0; +} + +/** A head at `tip`, pointing along `dir` (the way the line arrives there). */ +function capShape( + cap: BookLineCap, + [tip, dir]: [Pt, Pt], + { size, width }: { size: number; width: number }, + g: LineGeometry +): void { + if (cap === 'arrow') { + g.fills.push( + closed([tip, at(tip, dir, -size, size * 0.42), at(tip, dir, -size * 0.78, 0), at(tip, dir, -size, -size * 0.42)]) + ); + } else if (cap === 'open') { + const back = size * 0.9; + g.strokes.push(path([at(tip, dir, -back, back * 0.55), tip, at(tip, dir, -back, -back * 0.55)])); + } else if (cap === 'dot') { + g.fills.push(disc(tip, Math.max(width * 1.6, 0.8))); + } else if (cap === 'bar') { + g.strokes.push(path([at(tip, dir, 0, size * 0.45), at(tip, dir, 0, -size * 0.45)])); + } +} + +/** The motif in the middle, centred on `p` and turned along the line. */ +function ornamentShape(kind: BookLineOrnament, p: Pt, dir: Pt, size: number): string { + const s = size / 2; + switch (kind) { + case 'diamond': + return closed([at(p, dir, -s, 0), at(p, dir, 0, -s * 0.62), at(p, dir, s, 0), at(p, dir, 0, s * 0.62)]); + case 'dot': + return disc(p, s * 0.55); + case 'star': { + const pts: Pt[] = []; + for (let i = 0; i < 10; i++) { + const a = -Math.PI / 2 + (Math.PI / 5) * i; + const r = i % 2 === 0 ? s : s * 0.42; + pts.push([p[0] + r * Math.cos(a), p[1] + r * Math.sin(a)]); + } + return closed(pts); + } + case 'sun': { + const pts: Pt[] = []; + for (let i = 0; i < 24; i++) { + const a = (Math.PI / 12) * i; + const r = i % 2 === 0 ? s : s * 0.62; + pts.push([p[0] + r * Math.cos(a), p[1] + r * Math.sin(a)]); + } + return closed(pts); + } + case 'heart': { + const pts: Pt[] = []; + for (let i = 0; i < 32; i++) { + const t = (i / 32) * Math.PI * 2; + const x = (16 * Math.sin(t) ** 3) / 17; + const y = -(13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t)) / 17; + pts.push([p[0] + x * s, p[1] + y * s]); + } + return closed(pts); + } + default: + return ''; + } +} + +/** The dash pattern for a line of this width, in millimetres, kept visible on a hairline. */ +export function lineDash(dash: BookLineElement['dash'], width: number): string | undefined { + if (dash === 'dashed') return `${r2(Math.max(width * 3, 1.2))} ${r2(Math.max(width * 2, 0.9))}`; + if (dash === 'dotted') return `0.01 ${r2(Math.max(width * 2, 0.6))}`; + return undefined; +} + +/** The whole line, in the millimetres of a frame `w` by `h`. */ +export function lineGeometry(el: LineLook, w: number, h: number, id = 'line'): LineGeometry { + const width = el.strokeWidth; + const raw = el.points.map((p) => [p.x * w, p.y * h] as Pt); + const centre = el.curve === 'smooth' && raw.length > 2 ? smoothThrough(raw) : raw; + const acc = lengths(centre); + const total = acc[acc.length - 1]; + const size = bookLineCapSize(width); + const g: LineGeometry = { + body: [], + fills: [], + strokes: [], + dash: lineDash(el.dash, width), + cap: el.dash === 'dotted' || el.wobble > 0 ? 'round' : 'butt', + }; + if (total < 0.01) return g; + + const start = Math.min(total / 2, capTrim(el.startCap, size, width)); + const end = Math.max(total / 2, total - capTrim(el.endCap, size, width)); + const ornSize = el.ornament === 'none' ? 0 : 2.4 + width * 5; + const gap = ornSize ? ornSize / 2 + Math.max(1.2, ornSize * 0.45) : 0; + const mid = total / 2; + const pieces = gap ? [[start, mid - gap] as const, [mid + gap, end] as const] : [[start, end] as const]; + + const seed = seedOf(id); + pieces.forEach(([from, to], i) => { + const piece = slice(centre, acc, from, to, el.squiggle === 'none' && !el.wobble ? Infinity : 0.5); + const body = wobbled(squiggled(piece, el.squiggle, width), el.wobble, width, seed + i); + const d = path(body); + if (d) g.body.push(d); + }); + + const first = along(centre, acc, Math.min(0.01, total)); + const last = along(centre, acc, Math.max(0, total - 0.01)); + capShape(el.startCap, [centre[0], [-first.dir[0], -first.dir[1]]], { size, width }, g); + capShape(el.endCap, [centre[centre.length - 1], last.dir], { size, width }, g); + if (ornSize) { + const m = along(centre, acc, mid); + g.fills.push(ornamentShape(el.ornament, m.p, m.dir, ornSize)); + } + return g; +} diff --git a/client/src/components/Studio/lineSnap.ts b/client/src/components/Studio/lineSnap.ts new file mode 100644 index 000000000..87d2d1f7b --- /dev/null +++ b/client/src/components/Studio/lineSnap.ts @@ -0,0 +1,30 @@ +/** + * How a dragged point of a line settles: level or plumb when it comes within + * a couple of degrees of either, and to 15 degree steps while Shift is held. + * Measured against the point it hangs from, its neighbour on the line. + */ + +type Pt = [number, number]; + +/** How near, in degrees, a segment comes to level or plumb before it is made so. */ +const LEVEL_DEG = 2; +const STEP_DEG = 15; + +/** The point dragged to `p`, held level, plumb or to a step against the point it hangs from. */ +export function constrainPoint(p: Pt, anchor: Pt | undefined, stepped: boolean): Pt { + if (!anchor) return p; + const dx = p[0] - anchor[0]; + const dy = p[1] - anchor[1]; + const len = Math.hypot(dx, dy); + if (len < 0.01) return p; + const deg = (Math.atan2(dy, dx) * 180) / Math.PI; + let snapped: number | null = null; + if (stepped) snapped = Math.round(deg / STEP_DEG) * STEP_DEG; + else { + const square = Math.round(deg / 90) * 90; + if (Math.abs(deg - square) <= LEVEL_DEG) snapped = square; + } + if (snapped === null) return p; + const a = (snapped * Math.PI) / 180; + return [anchor[0] + Math.cos(a) * len, anchor[1] + Math.sin(a) * len]; +} diff --git a/client/src/components/Studio/paint/PaintedShape.tsx b/client/src/components/Studio/paint/PaintedShape.tsx index 37f179173..550eec7d5 100644 --- a/client/src/components/Studio/paint/PaintedShape.tsx +++ b/client/src/components/Studio/paint/PaintedShape.tsx @@ -1,5 +1,6 @@ import type { BookPaint, BookShapeElement } from '@trek/shared'; import { useMemo, type CSSProperties } from 'react'; +import { shapeOutline as edgedOutline, hasEdge } from '../shapeOutline'; import { HOLED_SHAPES } from '../shapes'; import { shapeOutline, strokeDash } from './paintGeometry'; import { PaintedPath } from './paintRender'; @@ -29,7 +30,11 @@ export function PaintedShapeView({ const sw = el.stroke ? el.strokeWidth : 0; const w = Math.max(0.01, el.frame.w - sw); const h = Math.max(0.01, el.frame.h - sw); - const d = useMemo(() => shapeOutline(el, w, h, sw / 2), [el, w, h, sw]); + // A cut edge (shapeOutline.ts) replaces the drawn outline, paint or none. + const d = useMemo( + () => (hasEdge(el, el.shape) ? edgedOutline(el, w, h) : shapeOutline(el, w, h, sw / 2)), + [el, w, h, sw] + ); const box = useMemo(() => ({ x: 0, y: 0, w, h }), [w, h]); return ( diff --git a/client/src/components/Studio/shapeDraw.ts b/client/src/components/Studio/shapeDraw.ts new file mode 100644 index 000000000..7b04b93f5 --- /dev/null +++ b/client/src/components/Studio/shapeDraw.ts @@ -0,0 +1,189 @@ +/** + * Drawing tools for the hand-made shapes: smooth outlines through points, + * arcs as cubics, strokes that taper, and a seeded random source for the + * shapes that are meant to look cut by hand. + * + * Everything here returns path text in the subset `scalePath` reads (absolute + * M, L, C, Z, x and y alternating) or plain point lists for it. Coordinates are + * free: the shape table normalises every path to its 0..100 box afterwards, so + * a shape is drawn at whatever size its arithmetic is easiest in. + */ + +export type Pt = [number, number]; + +const r2 = (n: number) => Math.round(n * 100) / 100; + +/** A point as path text, to two decimals. */ +export const fmt = ([x, y]: Pt) => `${r2(x)} ${r2(y)}`; + +/** + * Twice the signed area of a ring, with the sign the shape tests read: a ring + * drawn the way `circle()` is (clockwise on screen) comes out positive. + */ +export function signedArea(pts: readonly Pt[]): number { + let a = 0; + for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) { + a += pts[j][0] * pts[i][1] - pts[i][0] * pts[j][1]; + } + return a / 2; +} + +/** + * The ring wound clockwise on screen, the way every outline in the table is. + * + * Two parts of one shape that overlap must wind the same way, or under the + * non-zero fill rule the overlap cancels into a hole. + */ +export function clockwise(pts: readonly Pt[]): Pt[] { + return signedArea(pts) < 0 ? [...pts].reverse() : [...pts]; +} + +/** The opposite winding, for the inner ring of a shape with a hole. */ +export function anticlockwise(pts: readonly Pt[]): Pt[] { + return clockwise(pts).reverse(); +} + +/** A closed polygon through the points, straight between them. */ +export function polyPath(pts: readonly Pt[]): string { + return `M${pts.map(fmt).join('L')}Z`; +} + +/** + * A smooth closed outline through every point: a Catmull-Rom spline written as + * cubics. `tension` 0 is the plain spline; towards 1 the curve tightens onto + * the points, which is how a cut-out keeps a crisp tip without a corner. + * + * `sharp` names points the outline turns at rather than curves through, the + * tip of a leaf or the point of a star. + */ +export function smoothPath(pts: readonly Pt[], tension = 0, sharp: ReadonlySet = new Set()): string { + const n = pts.length; + const at = (i: number) => pts[((i % n) + n) % n]; + const k = (1 - tension) / 6; + let d = `M${fmt(at(0))}`; + for (let i = 0; i < n; i++) { + const p0 = at(i - 1); + const p1 = at(i); + const p2 = at(i + 1); + const p3 = at(i + 2); + const k1 = sharp.has(i) ? 0 : k; + const k2 = sharp.has((i + 1) % n) ? 0 : k; + const c1: Pt = [p1[0] + (p2[0] - p0[0]) * k1, p1[1] + (p2[1] - p0[1]) * k1]; + const c2: Pt = [p2[0] - (p3[0] - p1[0]) * k2, p2[1] - (p3[1] - p1[1]) * k2]; + d += `C${fmt(c1)} ${fmt(c2)} ${fmt(p2)}`; + } + return `${d}Z`; +} + +/** Points on a circle, angles in degrees clockwise from three o'clock (screen convention). */ +export function onCircle(cx: number, cy: number, r: number, deg: number): Pt { + const a = (deg * Math.PI) / 180; + return [cx + r * Math.cos(a), cy + r * Math.sin(a)]; +} + +/** + * An arc as cubics, from `a0` to `a1` degrees (either direction), without the + * move to its start: what follows an `M` or an `L` that already stands there. + * Split into pieces of at most 90 degrees, where a cubic is exact to 0.03%. + */ +export function arcTo(cx: number, cy: number, r: number, a0: number, a1: number): string { + const pieces = Math.max(1, Math.ceil(Math.abs(a1 - a0) / 90)); + const step = (a1 - a0) / pieces; + const h = (4 / 3) * Math.tan((step * Math.PI) / 720) * r; + let d = ''; + for (let i = 0; i < pieces; i++) { + const s = a0 + step * i; + const e = s + step; + const [sx, sy] = onCircle(cx, cy, r, s); + const [ex, ey] = onCircle(cx, cy, r, e); + const sa = (s * Math.PI) / 180; + const ea = (e * Math.PI) / 180; + const c1: Pt = [sx - h * Math.sin(sa), sy + h * Math.cos(sa)]; + const c2: Pt = [ex + h * Math.sin(ea), ey - h * Math.cos(ea)]; + d += `C${fmt(c1)} ${fmt(c2)} ${fmt([ex, ey])}`; + } + return d; +} + +/** A point list along a circular arc, ends included. */ +export function arcPoints([cx, cy]: Pt, r: number, [a0, a1]: [number, number], n: number): Pt[] { + return Array.from({ length: n + 1 }, (_, i) => onCircle(cx, cy, r, a0 + ((a1 - a0) * i) / n)); +} + +/** + * The outline of a stroke along a centre line, `width(t)` wide at each point + * (t from 0 to 1 along it): one side out, the other side back. A width of zero + * at an end gives it a point, which is how a leaf tip or a flourish's tail is + * cut. Returned as points, wound clockwise, for `smoothPath`. + */ +export function taper(centre: readonly Pt[], width: (t: number) => number): Pt[] { + const n = centre.length; + const sideA: Pt[] = []; + const sideB: Pt[] = []; + for (let i = 0; i < n; i++) { + const a = centre[Math.max(0, i - 1)]; + const b = centre[Math.min(n - 1, i + 1)]; + const len = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1; + const nx = -(b[1] - a[1]) / len; + const ny = (b[0] - a[0]) / len; + const half = width(n > 1 ? i / (n - 1) : 0) / 2; + const p = centre[i]; + sideA.push([p[0] + nx * half, p[1] + ny * half]); + sideB.push([p[0] - nx * half, p[1] - ny * half]); + } + // A zero width at an end draws the same point twice; keep one. + const same = (a: Pt, b: Pt) => Math.hypot(a[0] - b[0], a[1] - b[1]) <= 0.01; + const ring = [...sideA, ...sideB.reverse()].filter((p, i, all) => i === 0 || !same(p, all[i - 1])); + if (ring.length > 1 && same(ring[0], ring[ring.length - 1])) ring.pop(); + return clockwise(ring); +} + +/** A leaf of the laurel and fleuron kind: pointed at both ends, `w` wide, along the line from `a` to `b`. */ +export function leafAlong(a: Pt, b: Pt, w: number, bend = 0): Pt[] { + const dx = b[0] - a[0]; + const dy = b[1] - a[1]; + const len = Math.hypot(dx, dy) || 1; + const nx = -dy / len; + const ny = dx / len; + const pts: Pt[] = []; + const steps = 8; + for (let i = 0; i <= steps; i++) { + const t = i / steps; + const half = (w / 2) * Math.sin(Math.PI * t) ** 0.85 * (1 + bend * (t - 0.5)); + const sag = bend * len * 0.08 * Math.sin(Math.PI * t); + pts.push([a[0] + dx * t + nx * (half + sag), a[1] + dy * t + ny * (half + sag)]); + } + for (let i = steps - 1; i > 0; i--) { + const t = i / steps; + const half = (w / 2) * Math.sin(Math.PI * t) ** 0.85 * (1 - bend * (t - 0.5)); + const sag = bend * len * 0.08 * Math.sin(Math.PI * t); + pts.push([a[0] + dx * t - nx * (half - sag), a[1] + dy * t - ny * (half - sag)]); + } + return clockwise(pts); +} + +/** + * A small seeded random source (mulberry32), for the shapes and edges that are + * meant to look cut by hand. Seeded, so a torn edge tears the same way every + * time it is drawn, on screen and in the print. + */ +export function seeded(seed: number): () => number { + let s = seed >>> 0; + return () => { + s = (s + 0x6d2b79f5) >>> 0; + let t = s; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** A whole number from a string, the same every time: an element id as the seed of its torn edge. */ +export function seedOf(text: string): number { + let h = 0x811c9dc5; + for (let i = 0; i < text.length; i++) { + h ^= text.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return h >>> 0; +} diff --git a/client/src/components/Studio/shapeOutline.ts b/client/src/components/Studio/shapeOutline.ts new file mode 100644 index 000000000..cf83fd742 --- /dev/null +++ b/client/src/components/Studio/shapeOutline.ts @@ -0,0 +1,98 @@ +import { + BOOK_SHAPE_ASPECT, + type BookPhotoElement, + type BookShapeElement, + type BookShapeId, + bookShapeTakesEdge, +} from '@trek/shared'; + +import { seedOf } from './shapeDraw'; +import { circle, roundRect } from './shapePrimitives'; +import { SHAPE_PATHS, scalePath } from './shapes'; +import { edgedPath } from './shapes-edges'; + +/** + * The outline a shape or a photo's cut is drawn with, in millimetres, once + * its edge is applied. SpreadView asks here rather than reading the table + * directly, so the edge is one decision in one place. + */ + +/** The edge depth used when an edge is chosen and no size: about a postage stamp's. */ +export const DEFAULT_EDGE_SIZE = 3; + +type Edged = Pick; + +/** Whether the element's edge is cut along this outline (a heart, for one, keeps its drawn line). */ +export function hasEdge(el: Edged, shape: BookShapeId | null): boolean { + return !!el.edge && el.edge !== 'straight' && bookShapeTakesEdge(shape); +} + +/** A box `w` by `h` in millimetres, as the outline the edge is cut along: a rect keeps its corner radius. */ +function basePath(shape: BookShapeId | null, w: number, h: number, radius: number): string { + if (shape === null || shape === 'rect') { + const r = Math.min(radius, w / 2, h / 2); + return r > 0 ? roundRect(r, 0, 0, w, h) : `M0 0L${w} 0L${w} ${h}L0 ${h}Z`; + } + if (shape === 'ellipse') return circle(w / 2, h / 2, w / 2, h / 2); + return scalePath(SHAPE_PATHS[shape], w, h); +} + +/** The outline of a shape `w` by `h` millimetres, its edge cut where it takes one. */ +export function shapeOutline( + el: Edged & Pick, + w: number, + h: number +): string { + const edge = el.edge ?? 'straight'; + if (!hasEdge(el, el.shape)) return scalePath(SHAPE_PATHS[el.shape], w, h); + return edgedPath(basePath(el.shape, w, h, el.radius), edge, el.edgeSize ?? DEFAULT_EDGE_SIZE, seedOf(el.id)); +} + +/** + * The cut of a photo's picture box (`w` by `h` mm) as an objectBoundingBox + * clip, 0..1 on both axes; null when the picture is the plain rectangle. + * + * The edge is cut in millimetres and only then divided down, so a stamp's + * holes stay round on a wide photo, which they would not if the 0..1 path + * were cut instead. + */ +export function photoClip( + el: Pick, + w: number, + h: number +): string | null { + const mask = el.mask && el.mask !== 'rect' ? el.mask : null; + const edged = hasEdge(el, mask); + if (w <= 0 || h <= 0) return null; + let mm: string; + if (edged) { + mm = edgedPath( + basePath(mask, w, h, el.radius), + el.edge ?? 'straight', + el.edgeSize ?? DEFAULT_EDGE_SIZE, + seedOf(el.id) + ); + } else if (mask) { + mm = scalePath(SHAPE_PATHS[mask], w, h); + } else { + return null; + } + let axis = 0; + return mm.replace(/-?\d*\.?\d+/g, (m) => { + const v = Number(m) / (axis++ % 2 === 0 ? w : h); + return String(Math.round(v * 100000) / 100000); + }); +} + +/** + * A shape as a tile draws it: at the proportions it was drawn at, so a thin + * arrow is a thin arrow in the panel and not a square one. The first shelf + * has no recorded proportions and fills its square, as it always has. + */ +export function shapeTile(shape: BookShapeId): { d: string; w: number; h: number } { + const aspect = (BOOK_SHAPE_ASPECT as Readonly>)[shape]; + if (!aspect) return { d: SHAPE_PATHS[shape], w: 100, h: 100 }; + const w = aspect >= 1 ? 100 : 100 * aspect; + const h = aspect >= 1 ? 100 / aspect : 100; + return { d: scalePath(SHAPE_PATHS[shape], w, h), w, h }; +} diff --git a/client/src/components/Studio/shapePrimitives.ts b/client/src/components/Studio/shapePrimitives.ts new file mode 100644 index 000000000..c76ba98c1 --- /dev/null +++ b/client/src/components/Studio/shapePrimitives.ts @@ -0,0 +1,131 @@ +/** + * The building blocks the shape tables are drawn with, on a 0..100 square. + * + * Shared by shapes.ts, shapes-more.ts and shapes-cutouts.ts, which is why they + * live apart from any one table. Every path they build keeps to the subset + * `scalePath` depends on (see the note at the top of shapes.ts): absolute M, + * L, C, Q and Z, numbers alternating x and y. + */ + +/** Circle-to-cubic constant: the handle length that makes a quarter arc. */ +export const K = 0.5523; + +export const r2 = (n: number) => Math.round(n * 100) / 100; + +/** + * Stretch a ring of points to fill the 0..100 box exactly. + * + * A regular hexagon inscribed in a circle is 86.6 units wide and 100 tall, and + * drawn that way it would leave 7 units of nothing down each side of its own + * frame. In an editor that is a real problem rather than a cosmetic one: the + * selection outline, the resize handles and every snap target follow the frame, + * so a shape that does not fill its frame cannot be aligned to anything you can + * see. Filling the box is also what Canva does, and it costs a regularity + * nobody can spot: a hexagon in a square frame still reads as a hexagon. + */ +function fit(points: [number, number][]): string { + const xs = points.map((p) => p[0]); + const ys = points.map((p) => p[1]); + const minX = Math.min(...xs), + maxX = Math.max(...xs); + const minY = Math.min(...ys), + maxY = Math.max(...ys); + const sx = maxX > minX ? 100 / (maxX - minX) : 1; + const sy = maxY > minY ? 100 / (maxY - minY) : 1; + return `M${points.map(([x, y]) => `${r2((x - minX) * sx)} ${r2((y - minY) * sy)}`).join('L')}Z`; +} + +/** A regular polygon, first point at the top, stretched to fill the box. */ +export function polygon(sides: number, rotation = -90): string { + const pts: [number, number][] = []; + for (let i = 0; i < sides; i++) { + const a = ((rotation + (360 / sides) * i) * Math.PI) / 180; + pts.push([50 + 50 * Math.cos(a), 50 + 50 * Math.sin(a)]); + } + return fit(pts); +} + +/** + * A star, `points` tips alternating with `points` valleys. + * + * `inner` is the valley radius as a fraction of the tip radius, and it is the + * only number that decides whether a star looks like a star or like a gear: + * roughly 0.38 for five points, higher as the count grows, or the tips get so + * thin they disappear at print size. + */ +export function star(points: number, inner: number, rotation = -90): string { + const pts: [number, number][] = []; + for (let i = 0; i < points * 2; i++) { + const rad = i % 2 === 0 ? 50 : 50 * inner; + const a = ((rotation + (180 / points) * i) * Math.PI) / 180; + pts.push([50 + rad * Math.cos(a), 50 + rad * Math.sin(a)]); + } + return fit(pts); +} + +/** + * A scalloped edge: a wax seal, a sticker, a burst. + * + * Not a star with a soft inner radius: the lobes have to bulge *outwards* + * between the points, which needs a curve through each valley rather than a + * straight line to it. + */ +export function scallop(lobes: number, depth: number): string { + const step = (Math.PI * 2) / lobes; + /* + * The petal tip is the curve's midpoint, not its control point. + * + * A quadratic at t=0.5 sits a quarter of the way to each end and half the way + * to the control, so the furthest the outline actually reaches is + * `0.5·R·cos(Δ/2) + 0.5·rm`, well short of `rm` itself. Sizing from `rm` + * would draw a flower that overflows its box by a tenth and gets clipped; + * solving for that midpoint instead makes the drawn petals touch the edge + * exactly, which is what "normalised to 0..100" has to mean. + */ + const reach = 0.5 * Math.cos(step / 2) + 0.5 * (1 + depth); + const R = 50 / reach; + const rm = R * (1 + depth); + let d = ''; + for (let i = 0; i < lobes; i++) { + const a0 = -Math.PI / 2 + step * i; + const a1 = a0 + step; + const am = (a0 + a1) / 2; + const x0 = r2(50 + R * Math.cos(a0)), + y0 = r2(50 + R * Math.sin(a0)); + const x1 = r2(50 + R * Math.cos(a1)), + y1 = r2(50 + R * Math.sin(a1)); + const xm = r2(50 + rm * Math.cos(am)), + ym = r2(50 + rm * Math.sin(am)); + d += i === 0 ? `M${x0} ${y0}` : ''; + d += `Q${xm} ${ym} ${x1} ${y1}`; + } + return `${d}Z`; +} + +/** A circle as four cubics. */ +export function circle(cx = 50, cy = 50, rx = 50, ry = 50): string { + const kx = rx * K, + ky = ry * K; + return ( + `M${r2(cx)} ${r2(cy - ry)}` + + `C${r2(cx + kx)} ${r2(cy - ry)} ${r2(cx + rx)} ${r2(cy - ky)} ${r2(cx + rx)} ${r2(cy)}` + + `C${r2(cx + rx)} ${r2(cy + ky)} ${r2(cx + kx)} ${r2(cy + ry)} ${r2(cx)} ${r2(cy + ry)}` + + `C${r2(cx - kx)} ${r2(cy + ry)} ${r2(cx - rx)} ${r2(cy + ky)} ${r2(cx - rx)} ${r2(cy)}` + + `C${r2(cx - rx)} ${r2(cy - ky)} ${r2(cx - kx)} ${r2(cy - ry)} ${r2(cx)} ${r2(cy - ry)}Z` + ); +} + +/** A rounded rectangle, corner radius in the same 0..100 units. */ +export function roundRect(r: number, x = 0, y = 0, w = 100, h = 100): string { + const k = r * (1 - K); + return ( + `M${r2(x + r)} ${r2(y)}L${r2(x + w - r)} ${r2(y)}` + + `C${r2(x + w - k)} ${r2(y)} ${r2(x + w)} ${r2(y + k)} ${r2(x + w)} ${r2(y + r)}` + + `L${r2(x + w)} ${r2(y + h - r)}` + + `C${r2(x + w)} ${r2(y + h - k)} ${r2(x + w - k)} ${r2(y + h)} ${r2(x + w - r)} ${r2(y + h)}` + + `L${r2(x + r)} ${r2(y + h)}` + + `C${r2(x + k)} ${r2(y + h)} ${r2(x)} ${r2(y + h - k)} ${r2(x)} ${r2(y + h - r)}` + + `L${r2(x)} ${r2(y + r)}` + + `C${r2(x)} ${r2(y + k)} ${r2(x + k)} ${r2(y)} ${r2(x + r)} ${r2(y)}Z` + ); +} diff --git a/client/src/components/Studio/shapes-cutouts.ts b/client/src/components/Studio/shapes-cutouts.ts new file mode 100644 index 000000000..fe6da271e --- /dev/null +++ b/client/src/components/Studio/shapes-cutouts.ts @@ -0,0 +1,213 @@ +import { type Pt, clockwise, leafAlong, onCircle, polyPath, seeded, smoothPath, taper } from './shapeDraw'; +import { circle } from './shapePrimitives'; + +/** + * Paper cut-outs, in the manner of the late Matisse: leaves, coral, weed, + * wonky stars and a sun with flames for rays, and three not-quite circles. + * + * They are drawn from a few numbers and a fixed seed rather than traced, so + * every one is asymmetric the way a hand with scissors is, and the same on + * every machine. A cut-out reads by its silhouette, so each is one fill with + * no inner lines; the parts of the coral and the weed overlap and are wound + * alike, which joins them. + */ + +/** A closed outline from a radius at each angle, angles in degrees clockwise from the top. */ +function polar(n: number, radius: (deg: number, i: number) => number, cx = 50, cy = 50): Pt[] { + return Array.from({ length: n }, (_, i) => { + const deg = (360 / n) * i; + return onCircle(cx, cy, radius(deg, i), deg - 90); + }); +} + +/** A circle that wanders: a few slow waves added to its radius. */ +function organic(seed: number, amount: number): string { + const rnd = seeded(seed); + const waves = [2, 3, 5].map((k, i) => ({ k, a: amount * [1, 0.55, 0.3][i], p: rnd() * Math.PI * 2 })); + const pts = polar(28, (deg) => { + const t = (deg * Math.PI) / 180; + return 50 * (1 + waves.reduce((sum, w) => sum + w.a * Math.sin(w.k * t + w.p), 0)); + }); + return smoothPath(clockwise(pts)); +} + +/** Five points cut by hand: tips of different lengths, sides that bow, nothing quite regular. */ +function organicStar(): string { + const rnd = seeded(11); + const pts: Pt[] = []; + const sharp = new Set(); + for (let i = 0; i < 5; i++) { + const tipDeg = 72 * i + (rnd() - 0.5) * 10; + const valleyDeg = 72 * i + 36 + (rnd() - 0.5) * 8; + sharp.add(pts.length); + pts.push(onCircle(50, 50, 44 + rnd() * 8, tipDeg - 90)); + // A point either side of the valley, pushed out, bows the sides. + pts.push(onCircle(50, 50, 30 + rnd() * 3, valleyDeg - 104)); + pts.push(onCircle(50, 50, 19 + rnd() * 4, valleyDeg - 90)); + pts.push(onCircle(50, 50, 30 + rnd() * 3, valleyDeg - 76)); + } + return smoothPath(clockwise(pts), 0.35, sharp); +} + +/** A disc with flames for rays, each one leaning the same way, as a cut sun does. */ +function sunCutout(): string { + const rnd = seeded(5); + const rays = 14; + const pts: Pt[] = []; + const sharp = new Set(); + for (let i = 0; i < rays; i++) { + const a = (360 / rays) * i; + pts.push(onCircle(50, 50, 31 + rnd() * 2, a - 90)); + pts.push(onCircle(50, 50, 40, a + 360 / rays / 2 - 96)); + sharp.add(pts.length); + pts.push(onCircle(50, 50, 46 + rnd() * 4, a + 360 / rays / 2 - 84)); + } + return smoothPath(clockwise(pts), 0.15, sharp); +} + +/** A long willow leaf on a curving midrib, its stem left on. */ +function leafLong(): string { + const spine: Pt[] = []; + for (let i = 0; i <= 40; i++) { + const t = i / 40; + const u = 1 - t; + // A quadratic from the stem to the tip, bowed to one side. + spine.push([u * u * 34 + 2 * u * t * 58 + t * t * 44, u * u * 200 + 2 * u * t * 110 + t * t * 0]); + } + const width = (t: number) => (t < 0.07 ? 4 : 44 * Math.sin(Math.PI * ((t - 0.07) / 0.93) ** 1.15) ** 0.9 + 1.2); + return smoothPath(taper(spine, width), 0.1); +} + +/** An oak leaf: a midrib with rounded lobes, the two sides out of step. */ +function leafOak(): string { + const n = 80; + const sideA: Pt[] = []; + const sideB: Pt[] = []; + for (let i = 0; i <= n; i++) { + const t = i / n; + const y = 200 - t * 200; + const stem = t < 0.08; + const env = stem ? 3 : 46 * Math.sin(Math.PI * ((t - 0.08) / 0.92) ** 0.85) ** 0.75 + 1; + const lobe = (phase: number) => (stem ? 1 : 0.55 + 0.45 * Math.abs(Math.cos(Math.PI * 4.5 * t + phase)) ** 0.6); + sideA.push([50 - env * lobe(0), y]); + sideB.push([50 + env * lobe(0.9), y]); + } + return smoothPath(clockwise([...sideA, ...sideB.reverse()])); +} + +/** A palmette: five fingers fanned from one stem, the outer ones shorter. */ +function leafPalm(): string { + const pts: Pt[] = []; + const base: Pt = [100, 150]; + const steps = 90; + for (let i = 0; i <= steps; i++) { + const u = i / steps; + const deg = 200 + u * 140; + const finger = Math.abs(Math.sin(Math.PI * 5 * u)) ** 0.55; + const reach = 0.72 + 0.28 * Math.sin(Math.PI * u); + pts.push(onCircle(base[0], base[1], 38 + 108 * finger * reach, deg)); + } + // The stem, out of the bottom of the fan. + pts.push([108, 156], [106, 200], [96, 200], [93, 156]); + return polyPath(clockwise(pts.map(([x, y]) => [Math.round(x * 100) / 100, Math.round(y * 100) / 100] as Pt))); +} + +/** A monstera leaf: a broad heart with its slits cut in from the edge towards the midrib. */ +function leafMonstera(): string { + const pts: Pt[] = []; + const slits = [62, 92, 122, 238, 268, 298]; + const steps = 240; + for (let i = 0; i < steps; i++) { + const deg = (360 / steps) * i; + const t = (deg * Math.PI) / 180; + // The heart curve, with its notch at the top where the stalk joins. + const x = 16 * Math.sin(t) ** 3; + const y = -(13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t)); + const edge: Pt = [50 + x * 3, 48 - y * 3]; + const slit = slits.find((s) => Math.abs(deg - s) < 0.75); + if (slit !== undefined) { + // In towards the middle and back out: a cut two scissor strokes wide. + pts.push(edge); + pts.push([50 + (edge[0] - 50) * 0.42, 52 + (edge[1] - 52) * 0.42]); + } else { + pts.push(edge); + } + } + return polyPath(clockwise(pts.map(([x, y]) => [Math.round(x * 100) / 100, Math.round(y * 100) / 100] as Pt))); +} + +interface Branch { + from: Pt; + deg: number; + length: number; + width: number; +} + +/** + * Coral, grown: stubby branches with round ends, splitting into two or three + * that lean apart, from a few stems at the foot. Seeded, so the same coral + * every time. + */ +function coral(seed: number, depth: number, spread: number, stems: number): string { + const rnd = seeded(seed); + const parts: string[] = []; + const grow = (b: Branch, level: number) => { + const bend = (rnd() - 0.5) * 24; + const spine: Pt[] = []; + for (let i = 0; i <= 8; i++) spine.push(onCircle(b.from[0], b.from[1], b.length * (i / 8), b.deg + bend * (i / 8))); + const tipWidth = b.width * 0.82; + parts.push(smoothPath(taper(spine, (t) => b.width + (tipWidth - b.width) * t))); + const end = spine[spine.length - 1]; + // A round end, the way scissors cut a finger of paper. + parts.push(circle(end[0], end[1], tipWidth / 2, tipWidth / 2)); + if (level >= depth) return; + const kids = rnd() < 0.4 ? 3 : 2; + for (let k = 0; k < kids; k++) { + const lean = (k - (kids - 1) / 2) * spread + (rnd() - 0.5) * 14; + const next = { + from: end, + deg: b.deg + bend + lean, + length: b.length * (0.74 + rnd() * 0.2), + width: tipWidth * 0.9, + }; + grow(next, level + 1); + } + }; + for (let i = 0; i < stems; i++) { + const deg = -90 + (i - (stems - 1) / 2) * 30 + (rnd() - 0.5) * 8; + grow({ from: [100 + (i - (stems - 1) / 2) * 8, 200], deg, length: 48 + rnd() * 10, width: 19 }, 0); + } + return parts.join(''); +} + +/** A frond of weed: a swaying stem with blades along it, alternately left and right. */ +function seaweed(): string { + const spine: Pt[] = []; + for (let i = 0; i <= 30; i++) { + const t = i / 30; + spine.push([50 + 12 * Math.sin(t * Math.PI * 2.2), 200 - t * 196]); + } + const parts = [smoothPath(taper(spine, (t) => 9 - t * 6))]; + for (let i = 3; i < 28; i += 3) { + const at = spine[i]; + const side = i % 2 === 0 ? 1 : -1; + const tip: Pt = [at[0] + side * (34 - i * 0.6), at[1] - 26 + i * 0.2]; + parts.push(smoothPath(leafAlong(at, tip, 13 - i * 0.2, 0.4 * side))); + } + return parts.join(''); +} + +export const CUTOUT_PATHS = { + 'leaf-palm': leafPalm(), + 'leaf-oak': leafOak(), + 'leaf-long': leafLong(), + 'leaf-monstera': leafMonstera(), + 'coral-1': coral(3, 3, 40, 2), + 'coral-2': coral(19, 3, 34, 3), + seaweed: seaweed(), + 'star-organic': organicStar(), + 'sun-cutout': sunCutout(), + 'organic-1': organic(2, 0.07), + 'organic-2': organic(9, 0.09), + 'organic-3': organic(31, 0.06), +} as const; diff --git a/client/src/components/Studio/shapes-edges.ts b/client/src/components/Studio/shapes-edges.ts new file mode 100644 index 000000000..13c0e74b4 --- /dev/null +++ b/client/src/components/Studio/shapes-edges.ts @@ -0,0 +1,239 @@ +import type { BookEdge } from '@trek/shared'; + +import { type Pt, seeded, signedArea } from './shapeDraw'; + +/** + * Cut edges: an outline replaced by pinking-shear teeth, scallops, a postage + * stamp's perforation, a wave, a torn sheet or the soft deckle of handmade + * paper. + * + * ── How it is cut ──────────────────────────────────────────────────────── + * + * The outline is first flattened into points in millimetres and split at its + * corners into runs: a rectangle is four, a circle one. Each run gets a whole + * number of teeth, stretched a little to fit, so a tooth never ends half way + * along a side and every corner lands on the outline itself. Then each point + * is pushed inwards along the outline's normal by the profile of the edge. + * + * Inwards, always: the cut bites into the shape and never past its frame, so + * what the editor snaps is still where the shape ends. + * + * Millimetres rather than the 0..100 box, because a tooth is a physical size. + * A stamp's perforation is a few millimetres whatever the stamp, and a frame + * twice as wide gets twice the holes, not holes twice as wide. + */ + +/** The most points an edge draws: a spread-wide strip at a fine edge stays under it. */ +const MAX_POINTS = 3000; + +/** Under this many degrees between two segments the outline is a curve, past it a corner. */ +const CORNER_DEG = 24; + +const r2 = (n: number) => Math.round(n * 100) / 100; + +/** The outline as points, curves flattened; only the first subpath, which is the outline of the shapes that take an edge. */ +export function flattenPath(d: string, step = 0.5): Pt[] { + const nums = (d.match(/-?\d*\.?\d+(?:e-?\d+)?/g) ?? []).map(Number); + const cmds = d.match(/[MLCQZ]/g) ?? []; + const pts: Pt[] = []; + let i = 0; + let cx = 0; + let cy = 0; + for (const cmd of cmds) { + if (cmd === 'Z') break; + if (cmd === 'M' || cmd === 'L') { + cx = nums[i++]; + cy = nums[i++]; + pts.push([cx, cy]); + continue; + } + const c = nums.slice(i, i + (cmd === 'Q' ? 4 : 6)); + i += c.length; + const [x, y] = [c[c.length - 2], c[c.length - 1]]; + const chord = Math.hypot(x - cx, y - cy) + Math.hypot(c[0] - cx, c[1] - cy); + const n = Math.max(4, Math.min(80, Math.ceil(chord / step))); + for (let k = 1; k <= n; k++) { + const t = k / n; + const u = 1 - t; + if (cmd === 'Q') { + pts.push([u * u * cx + 2 * u * t * c[0] + t * t * x, u * u * cy + 2 * u * t * c[1] + t * t * y]); + } else { + pts.push([ + u * u * u * cx + 3 * u * u * t * c[0] + 3 * u * t * t * c[2] + t * t * t * x, + u * u * u * cy + 3 * u * u * t * c[1] + 3 * u * t * t * c[3] + t * t * t * y, + ]); + } + } + cx = x; + cy = y; + } + // The closing point repeats the first on most outlines; one is enough. + const out = pts.filter((p, k) => k === 0 || Math.hypot(p[0] - pts[k - 1][0], p[1] - pts[k - 1][1]) > 1e-6); + const first = out[0]; + const last = out[out.length - 1]; + if (first && last && out.length > 1 && Math.hypot(first[0] - last[0], first[1] - last[1]) < 1e-6) out.pop(); + return out; +} + +function turnAt(prev: Pt, here: Pt, next: Pt): number { + const a = Math.atan2(here[1] - prev[1], here[0] - prev[0]); + const b = Math.atan2(next[1] - here[1], next[0] - here[0]); + let d = ((b - a) * 180) / Math.PI; + while (d > 180) d -= 360; + while (d < -180) d += 360; + return Math.abs(d); +} + +/** The outline split at its corners into runs, each from one corner to the next; a smooth ring is one run. */ +export function outlineRuns(ring: readonly Pt[]): Pt[][] { + const n = ring.length; + const corners: number[] = []; + for (let k = 0; k < n; k++) { + if (turnAt(ring[(k - 1 + n) % n], ring[k], ring[(k + 1) % n]) > CORNER_DEG) corners.push(k); + } + if (!corners.length) return [[...ring, ring[0]]]; + return corners.map((start, c) => { + const end = corners[(c + 1) % corners.length]; + const run: Pt[] = []; + for (let k = start; ; k = (k + 1) % n) { + run.push(ring[k]); + if (k === end && run.length > 1) break; + } + return run; + }); +} + +/** A run resampled every `step` along its length, both ends kept. */ +function resample(run: readonly Pt[], count: number): { pts: Pt[]; length: number } { + const lengths = [0]; + for (let k = 1; k < run.length; k++) { + lengths.push(lengths[k - 1] + Math.hypot(run[k][0] - run[k - 1][0], run[k][1] - run[k - 1][1])); + } + const length = lengths[lengths.length - 1]; + const pts: Pt[] = []; + let seg = 1; + for (let k = 0; k <= count; k++) { + const s = (length * k) / count; + while (seg < run.length - 1 && lengths[seg] < s) seg++; + const s0 = lengths[seg - 1]; + const s1 = lengths[seg]; + const t = s1 > s0 ? (s - s0) / (s1 - s0) : 0; + const a = run[seg - 1]; + const b = run[seg]; + pts.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]); + } + return { pts, length }; +} + +/** How far in an edge cuts at a point `t` teeth along (t in 0..1 within one tooth), as a share of its depth. */ +type Profile = (t: number, index: number) => number; + +/** Smooth noise along a run: values at whole steps, eased between. */ +function noise(rnd: () => number, n: number): (x: number) => number { + const values = Array.from({ length: n + 2 }, () => rnd()); + return (x) => { + const k = Math.floor(x); + const f = x - k; + const e = f * f * (3 - 2 * f); + return values[k % values.length] * (1 - e) + values[(k + 1) % values.length] * e; + }; +} + +interface EdgeSpec { + /** An irregular edge eases out towards a corner, or each corner grows a spike where two tears meet. */ + eases?: boolean; + /** Teeth per tooth length: how many points one tooth is drawn with. */ + samples: number; + /** Depth of the cut, as a share of the edge size. */ + depth: number; + profile: (rnd: () => number, teeth: number) => Profile; +} + +/* + * Each profile is 0 at the ends of a tooth, so corners stay on the outline, + * and at most 1 inside it. + */ +const SPECS: Record, EdgeSpec> = { + // Pinking shears: a point at each end, a valley in the middle. + zigzag: { samples: 2, depth: 0.5, profile: () => (t) => (Math.abs(t - 0.5) < 0.01 ? 1 : 0) }, + // Half circles bulging out from each end of a tooth, meeting in a valley at its middle. + scallop: { + samples: 12, + depth: 0.5, + profile: () => (t) => 1 - Math.sqrt(Math.max(0, 1 - (2 * Math.min(t, 1 - t)) ** 2)), + }, + // The edge runs through a row of round holes: a half circle out of the middle of each tooth. + perforated: { + samples: 16, + depth: 0.3, + profile: () => (t) => { + const r = 0.3; + const u = t - 0.5; + return Math.abs(u) < r ? Math.sqrt(r * r - u * u) / r : 0; + }, + }, + wavy: { samples: 12, depth: 0.35, profile: () => (t) => 0.5 - 0.5 * Math.cos(Math.PI * 2 * t) }, + // Torn paper: an uneven slope with sharp little teeth on it. + torn: { + eases: true, + samples: 3, + depth: 0.8, + profile: (rnd, teeth) => { + const slow = noise(rnd, teeth + 4); + return (t, index) => 0.55 * slow((index + t) / 3) + 0.45 * rnd(); + }, + }, + // Handmade paper: soft, slow, never sharp. + deckle: { + eases: true, + samples: 6, + depth: 0.5, + profile: (rnd, teeth) => { + const slow = noise(rnd, teeth + 4); + const fine = noise(rnd, teeth * 3 + 6); + return (t, index) => 0.7 * slow(index + t) + 0.3 * fine((index + t) * 3); + }, + }, +}; + +/** + * The outline `d` (millimetres) with its edge cut, `size` millimetres per + * tooth. `seed` decides how a torn or deckled edge tears; the element id is + * the usual one, so the same element tears the same way every time. + */ +export function edgedPath(d: string, edge: BookEdge, size: number, seed = 1): string { + if (edge === 'straight' || size <= 0) return d; + const ring = flattenPath(d); + if (ring.length < 3) return d; + // Inwards is to the right of travel on a ring wound clockwise on screen. + const inward = signedArea(ring) >= 0 ? 1 : -1; + const spec = SPECS[edge]; + const rnd = seeded(seed); + const runs = outlineRuns(ring); + const perimeter = runs.reduce((sum, run) => sum + resample(run, 1).length, 0); + // Coarser teeth past the point cap, rather than a path nobody can render. + const tooth = Math.max(size, (perimeter * spec.samples) / MAX_POINTS); + const depth = tooth * spec.depth; + const out: Pt[] = []; + for (const run of runs) { + const { length } = resample(run, 1); + const teeth = Math.max(1, Math.round(length / tooth)); + const { pts } = resample(run, teeth * spec.samples); + const profile = spec.profile(rnd, teeth); + for (let k = 0; k < pts.length - 1; k++) { + const a = pts[Math.max(0, k - 1)]; + const b = pts[Math.min(pts.length - 1, k + 1)]; + const len = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1; + const nx = (-(b[1] - a[1]) / len) * inward; + const ny = ((b[0] - a[0]) / len) * inward; + const index = Math.floor(k / spec.samples); + const t = (k % spec.samples) / spec.samples; + const fromEnd = Math.min(k, pts.length - 1 - k) / spec.samples; + const ease = spec.eases && runs.length > 1 ? Math.min(1, fromEnd / 1.5) : 1; + const cut = k === 0 ? 0 : depth * profile(t, index) * ease; + const p = pts[k]; + out.push([p[0] + nx * cut, p[1] + ny * cut]); + } + } + return `M${out.map(([x, y]) => `${r2(x)} ${r2(y)}`).join('L')}Z`; +} diff --git a/client/src/components/Studio/shapes-more.ts b/client/src/components/Studio/shapes-more.ts new file mode 100644 index 000000000..4c1eaf601 --- /dev/null +++ b/client/src/components/Studio/shapes-more.ts @@ -0,0 +1,349 @@ +import type { BOOK_SHAPES_MORE } from '@trek/shared'; + +import { + type Pt, + anticlockwise, + arcPoints, + arcTo, + clockwise, + fmt, + onCircle, + polyPath, + seeded, + smoothPath, +} from './shapeDraw'; +import { K, circle, roundRect, scallop } from './shapePrimitives'; +import { CUTOUT_PATHS } from './shapes-cutouts'; +import { ORNAMENT_PATHS } from './shapes-ornaments'; + +/** + * The second shelf of the shape table (book-shapes-more.ts lists the ids). + * + * Each is drawn at the size its arithmetic is easiest in and normalised to + * the 0..100 box by the table, like every other shape. What a shape looks + * like at its natural proportions is in `BOOK_SHAPE_ASPECT` (shared), which is + * what the panel places it at and what its tile is drawn in. + * + * The rules of the first shelf hold here too (see shapes.ts): one fill, every + * part wound clockwise so overlapping parts join, holes only in the shapes + * listed in `HOLED_SHAPES`, and nothing thin enough to vanish in print. + */ + +type MoreId = (typeof BOOK_SHAPES_MORE)[number]; +type Own = Exclude; + +/** A semicircle over a base line, left foot to right foot, as cubics: the head of an arch. */ +function head(x0: number, x1: number, y: number): string { + const r = (x1 - x0) / 2; + const c = x0 + r; + return `C${x0} ${y - r * K} ${c - r * K} ${y - r} ${c} ${y - r}C${c + r * K} ${y - r} ${x1} ${y - r * K} ${x1} ${y}`; +} + +/** A half ring over a base line: one band of a rainbow. */ +function band(cx: number, base: number, outer: number, inner: number): string { + return `M${cx - outer} ${base}${arcTo(cx, base, outer, 180, 360)}L${cx + inner} ${base}${arcTo(cx, base, inner, 360, 180)}Z`; +} + +/** + * A perforated outline, as a sheet of stamps is torn: the edge runs through a + * row of round holes, so it bites a half circle out every `pitch`. + */ +function perforate(ring: readonly Pt[], pitch: number, radius: number): string { + const out: Pt[] = []; + const n = ring.length; + for (let i = 0; i < n; i++) { + const a = ring[i]; + const b = ring[(i + 1) % n]; + const len = Math.hypot(b[0] - a[0], b[1] - a[1]); + const holes = Math.max(1, Math.round(len / pitch)); + const step = len / holes; + const ux = (b[0] - a[0]) / len; + const uy = (b[1] - a[1]) / len; + // Inward is to the right of the direction of travel on a clockwise ring. + const nx = -uy; + const ny = ux; + out.push(a); + for (let h = 0; h < holes; h++) { + const mid = step * (h + 0.5); + for (let s = 0; s <= 8; s++) { + const ang = Math.PI * (s / 8); + const along = mid - radius * Math.cos(ang); + const depth = radius * Math.sin(ang); + out.push([a[0] + ux * along + nx * depth, a[1] + uy * along + ny * depth]); + } + } + } + return polyPath(out); +} + +/** A ring of points around a circle, for edges cut along one. */ +const circleRing = (cx: number, cy: number, r: number, n: number) => arcPoints([cx, cy], r, [-90, 270], n).slice(0, n); + +/** A stamp cut round: a disc whose edge runs through a ring of holes. */ +// One hole on every side of a fine polygon: the pitch is the side itself. +const perforatedDisc = (holes: number) => perforate(circleRing(50, 50, 50, holes), Infinity, 2.9); + +/** An arrow bent along a circle (its centre and radius): a band between two arcs with a head at the end. */ +function arcArrow( + [cx, cy, r]: [number, number, number], + thick: number, + [a0, a1]: [number, number], + head: { length: number; wing: number } +): string { + const ro = r + thick / 2; + const ri = r - thick / 2; + const dir = Math.sign(a1 - a0); + // The head starts short of the end of the arc and points along it. + const aEnd = a1 - (dir * ((head.length / r) * 180)) / Math.PI; + const wing = thick * head.wing; + const at = (rad: number, deg: number) => fmt(onCircle(cx, cy, rad, deg)); + return ( + `M${at(ro, a0)}${arcTo(cx, cy, ro, a0, aEnd)}L${at(r + wing, aEnd)}L${at(r, a1)}L${at(r - wing, aEnd)}` + + `L${at(ri, aEnd)}${arcTo(cx, cy, ri, aEnd, a0)}Z` + ); +} + +/** A spiky outline, irregular on purpose: what a shout looks like in a comic. */ +function shout(): string { + const rnd = seeded(7); + const pts: Pt[] = []; + const spikes = 13; + for (let i = 0; i < spikes; i++) { + const a = -90 + (360 / spikes) * i; + // The spike at about seven o'clock is the tail, long and pointing down. + const tail = i === 8; + const outer = tail ? 66 : 47 + rnd() * 7; + pts.push(onCircle(50, 50, outer, a + (tail ? -6 : 0))); + pts.push(onCircle(50, 50, 33 + rnd() * 5, a + 360 / spikes / 2)); + } + return polyPath(clockwise(pts)); +} + +/** A torn strip of paper: straight-ish ends, both long edges torn. */ +function tornStrip(): string { + const rnd = seeded(23); + const top: Pt[] = []; + const bottom: Pt[] = []; + for (let x = 0; x <= 200; x += 5) { + top.push([x, 4 + rnd() * 6 + (rnd() < 0.2 ? 3 : 0)]); + bottom.push([x, 46 - rnd() * 6 - (rnd() < 0.2 ? 3 : 0)]); + } + return polyPath(clockwise([...top, ...bottom.reverse()])); +} + +/** A band whose two long edges are the same wave, so it keeps one width. */ +function wavyBand(): string { + const pts: Pt[] = []; + const wave = (x: number) => 14 * Math.sin((x / 200) * Math.PI * 4); + for (let x = 0; x <= 200; x += 5) pts.push([x, 20 + wave(x)]); + for (let x = 200; x >= 0; x -= 5) pts.push([x, 56 + wave(x)]); + return smoothPath(clockwise(pts), 0, new Set([40, 41, 81])); +} + +/** A curved banner: an arc of a band with its ends cut into swallow tails. */ +function ribbonArc(): string { + const cx = 100; + const cy = 260; + const ro = 240; + const ri = 196; + const mid = (ro + ri) / 2; + const a0 = 248; + const a1 = 292; + const at = (rad: number, deg: number) => fmt(onCircle(cx, cy, rad, deg)); + return ( + `M${at(ro, a0)}${arcTo(cx, cy, ro, a0, a1)}L${at(mid, a1 - 3.5)}L${at(ri, a1)}` + + `${arcTo(cx, cy, ri, a1, a0)}L${at(mid, a0 + 3.5)}Z` + ); +} + +/** A star with its tips and valleys rounded off: softer, friendlier, a sticker's star. */ +function roundedStar(points: number, inner: number, round: number): string { + const pts: Pt[] = []; + for (let i = 0; i < points * 2; i++) { + pts.push(onCircle(50, 50, i % 2 === 0 ? 50 : 50 * inner, -90 + (180 / points) * i)); + } + const n = pts.length; + let d = ''; + for (let i = 0; i < n; i++) { + const prev = pts[(i - 1 + n) % n]; + const here = pts[i]; + const next = pts[(i + 1) % n]; + const f = i % 2 === 0 ? round : round * 0.6; + const a: Pt = [here[0] + (prev[0] - here[0]) * f, here[1] + (prev[1] - here[1]) * f]; + const b: Pt = [here[0] + (next[0] - here[0]) * f, here[1] + (next[1] - here[1]) * f]; + d += `${i === 0 ? 'M' : 'L'}${fmt(a)}Q${fmt(here)} ${fmt(b)}`; + } + return `${d}Z`; +} + +/** An eight-ray twinkle: four long rays and four short, every side drawn in. */ +function twinkle(): string { + const tip = (i: number) => onCircle(50, 50, i % 2 === 0 ? 50 : 27, -90 + 45 * i); + let d = `M${fmt(tip(0))}`; + for (let i = 0; i < 8; i++) { + d += `Q${fmt(onCircle(50, 50, 7, -90 + 45 * i + 22.5))} ${fmt(tip(i + 1))}`; + } + return `${d}Z`; +} + +/** A sun's rays as wedges between gaps, from a small hub, as a sunburst background is printed. */ +function sunburst(rays: number): string { + const pts: Pt[] = []; + const half = 360 / rays / 4; + for (let i = 0; i < rays; i++) { + const a = -90 + (360 / rays) * i; + pts.push(onCircle(50, 50, 5, a - half * 1.6)); + pts.push(onCircle(50, 50, 50, a - half)); + pts.push(onCircle(50, 50, 50, a + half)); + pts.push(onCircle(50, 50, 5, a + half * 1.6)); + } + return polyPath(clockwise(pts)); +} + +/** A rosette: a frilled disc over two tails cut into swallow points. */ +function rosette(): string { + const disc = scallop(18, 0.07); + // The tails hang from under the disc, splayed, each cut into a V. + const left = polyPath( + clockwise([ + [34, 62], + [52, 70], + [38, 132], + [29, 118], + [17, 124], + ]) + ); + const right = polyPath( + clockwise([ + [48, 70], + [66, 62], + [83, 124], + [71, 118], + [62, 132], + ]) + ); + return `${disc}${left}${right}`; +} + +/** Two hearts, the smaller one tucked in front and tilted, for a page about two people. */ +function heartPair(): string { + const heart = (cx: number, cy: number, s: number, turn: number): string => { + const raw: Pt[] = []; + for (let i = 0; i < 48; i++) { + const t = (i / 48) * Math.PI * 2; + // The classic heart curve, which has a real point and two round lobes. + const x = 16 * Math.sin(t) ** 3; + const y = -(13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t)); + const a = (turn * Math.PI) / 180; + raw.push([cx + s * (x * Math.cos(a) - y * Math.sin(a)), cy + s * (x * Math.sin(a) + y * Math.cos(a))]); + } + return smoothPath(clockwise(raw), 0.2, new Set([0, 24])); + }; + return `${heart(42, 46, 2.5, -12)}${heart(84, 74, 1.7, 14)}`; +} + +/** A plump heart: round lobes, a shallow notch and a soft point. */ +const HEART_ROUND = + 'M50 96C44 92 0 68 0 34C0 14 14 2 30 2C40 2 47 8 50 15C53 8 60 2 70 2C86 2 100 14 100 34C100 68 56 92 50 96Z'; + +/** A rectangle whose sides bow out a little and whose corners are round: an old television. */ +const CUSHION = + 'M20 0C40 -4 60 -4 80 0C92 2 98 8 100 20C104 40 104 60 100 80C98 92 92 98 80 100' + + 'C60 104 40 104 20 100C8 98 2 92 0 80C-4 60 -4 40 0 20C2 8 8 2 20 0Z'; + +const MORE_OWN_PATHS: Record = { + 'arch-tall': `M0 200L0 50${head(0, 100, 50)}L100 200Z`, + 'arch-pointed': 'M0 190L0 86.6C0 50.9 19.1 17.85 50 0C80.9 17.85 100 50.9 100 86.6L100 190Z', + 'arch-double': `M0 160L0 50${head(0, 100, 50)}${head(100, 200, 50)}L200 160Z`, + 'arch-half': `M0 160L0 100C0 ${100 - 100 * K} ${100 - 100 * K} 0 100 0L100 160Z`, + 'arch-rainbow': `${band(100, 100, 100, 76)}${band(100, 100, 68, 44)}${band(100, 100, 36, 12)}`, + + 'stamp-rect': perforate( + [ + [0, 0], + [100, 0], + [100, 124], + [0, 124], + ], + 11, + 4 + ), + 'stamp-circle': perforatedDisc(34), + postmark: + `${circle(50, 50, 50, 50)}${polyPath(anticlockwise(circleRing(50, 50, 43, 48)))}` + + `${circle(50, 50, 37, 37)}${polyPath(anticlockwise(circleRing(50, 50, 34, 48)))}`, + rosette: rosette(), + 'scallop-circle': scallop(24, 0.075), + 'badge-shield': 'M0 4Q50 18 100 4L100 52C100 78 76 93 50 100C24 93 0 78 0 52Z', + 'label-tab': + 'M30 0L154 0C157.31 0 160 2.69 160 6L160 74C160 77.31 157.31 80 154 80L30 80L0 54L0 26Z' + + polyPath(anticlockwise(circleRing(24, 40, 7, 24))), + 'ticket-stub': + 'M0 0L141 0C141 5 145 9 150 9C155 9 159 5 159 0L200 0L200 90L159 90C159 85 155 81 150 81' + + 'C145 81 141 85 141 90L0 90Z', + 'sunburst-12': sunburst(12), + 'sunburst-24': sunburst(24), + + 'ribbon-ends': + 'M30 0L170 0L170 40L30 40Z' + + polyPath( + clockwise([ + [0, 14], + [44, 14], + [44, 54], + [0, 54], + [12, 34], + ]) + ) + + polyPath( + clockwise([ + [156, 14], + [200, 14], + [188, 34], + [200, 54], + [156, 54], + ]) + ), + 'ribbon-arc': ribbonArc(), + 'wavy-band': wavyBand(), + 'torn-strip': tornStrip(), + + 'bubble-shout': shout(), + 'bubble-side': `${roundRect(20, 16, 0, 84, 70)}${polyPath( + clockwise([ + [24, 36], + [24, 60], + [0, 70], + ]) + )}`, + 'bubble-capsule': `${roundRect(35, 0, 0, 200, 70)}${polyPath( + clockwise([ + [52, 60], + [84, 60], + [40, 96], + ]) + )}`, + + 'arrow-curve': arcArrow([100, 170, 140], 16, [222, 318], { length: 34, wing: 1.55 }), + 'arrow-circle': arcArrow([50, 50, 40], 14, [-70, 225], { length: 18, wing: 1.3 }), + 'arrow-notched': 'M0 25L130 25L130 0L200 50L130 100L130 75L0 75L22 50Z', + 'arrow-thin': 'M0 47L168 47L160 32L200 50L160 68L168 53L0 53Z', + + 'heart-round': HEART_ROUND, + 'heart-pair': heartPair(), + 'star-rounded': roundedStar(5, 0.46, 0.2), + 'star-twinkle': twinkle(), + 'flower-8': scallop(8, 0.34), + + 'frame-notched': + 'M12 0L88 0C88 6.63 93.37 12 100 12L100 88C93.37 88 88 93.37 88 100L12 100' + + 'C12 93.37 6.63 88 0 88L0 12C6.63 12 12 6.63 12 0Z', + 'frame-chamfer': 'M14 0L86 0L100 14L100 86L86 100L14 100L0 86L0 14Z', + 'frame-cushion': CUSHION, +}; + +export const MORE_SHAPE_PATHS: Record = { + ...MORE_OWN_PATHS, + ...CUTOUT_PATHS, + ...ORNAMENT_PATHS, +}; diff --git a/client/src/components/Studio/shapes-ornaments.ts b/client/src/components/Studio/shapes-ornaments.ts new file mode 100644 index 000000000..a0bd3dc0b --- /dev/null +++ b/client/src/components/Studio/shapes-ornaments.ts @@ -0,0 +1,142 @@ +import { type Pt, clockwise, leafAlong, onCircle, smoothPath, taper } from './shapeDraw'; + +/** + * Ornaments a title is set between: a scroll, a laurel wreath, a fleuron and + * a corner flourish. Each is drawn as swelling strokes and leaves, wound alike + * so the parts join into one fill. + * + * The strokes taper to a point rather than ending square, and nothing in them + * is thinner than about one unit in a hundred, which at the size a divider is + * set (60 mm and up) is a line a printer still holds. + */ + +/** A spiral's centre line, from its outer end inwards, `turns` times round. */ +interface Spiral { + /** The centre it winds round. */ + at: Pt; + /** The radius it starts at and the one it ends at. */ + from: number; + to: number; + /** Where it starts, in degrees clockwise from three o'clock. */ + deg: number; + turns: number; + /** 1 winds clockwise, -1 the other way. */ + dir: 1 | -1; +} + +function spiral({ at: [cx, cy], from: r0, to: r1, deg: deg0, turns, dir }: Spiral): Pt[] { + const n = Math.round(24 * turns); + return Array.from({ length: n + 1 }, (_, i) => { + const t = i / n; + return onCircle(cx, cy, r0 + (r1 - r0) * t, deg0 + dir * 360 * turns * t); + }); +} + +/** A stroke that swells in the middle and thins to both ends, the way a pen draws a flourish. */ +const swell = + (thick: number, end = 0.9) => + (t: number) => + end + (thick - end) * Math.sin(Math.PI * t) ** 0.9; + +/** One half of the scroll, drawn to the right of the middle; the left is its mirror. */ +function scrollHalf(dir: 1 | -1): string[] { + const x = (v: number) => 100 + dir * v; + const arm: Pt[] = []; + for (let i = 0; i <= 24; i++) { + const t = i / 24; + arm.push([x(9 + t * 64), 20 - 7 * Math.sin(t * Math.PI)]); + } + const curl = spiral({ at: [x(82), 22], from: 9, to: 2.5, deg: 270, turns: 0.85, dir }); + const line = [...arm, ...curl.slice(1)]; + const leaf = leafAlong([x(40), 16], [x(54), 4], 6, 0.6 * dir); + return [smoothPath(taper(line, swell(4.2)), 0.05), smoothPath(leaf)]; +} + +/** A horizontal scroll: a lozenge in the middle and an arm curling away to each side. */ +function flourishScroll(): string { + const lozenge: Pt[] = [ + [100, 13], + [108, 20], + [100, 27], + [92, 20], + ]; + return [smoothPath(lozenge, 0.6, new Set([0, 1, 2, 3])), ...scrollHalf(1), ...scrollHalf(-1)].join(''); +} + +/** One branch of a laurel: a stem round the circle with paired leaves leaning forwards. */ +function laurelBranch(dir: 1 | -1): string[] { + const cx = 100; + const cy = 96; + const r = 82; + const from = dir === 1 ? 98 : 82; + const to = dir === 1 ? 228 : -48; + const stem: Pt[] = Array.from({ length: 31 }, (_, i) => onCircle(cx, cy, r, from + ((to - from) * i) / 30)); + const parts = [smoothPath(taper(stem, (t) => 3.6 - t * 2.4))]; + const leaves = 9; + for (let i = 0; i < leaves; i++) { + const deg = from + ((to - from) * (i + 0.6)) / (leaves + 0.4); + const at = onCircle(cx, cy, r, deg); + const size = 30 - i * 1.4; + // Each leaf leans along the stem, one outside the circle and one inside it. + const ahead = deg + dir * 14; + parts.push(smoothPath(leafAlong(at, onCircle(cx, cy, r + size * 0.62, ahead), size * 0.36, 0.3))); + if (i < leaves - 1) + parts.push(smoothPath(leafAlong(at, onCircle(cx, cy, r - size * 0.55, ahead), size * 0.32, -0.3))); + } + // A last leaf at the very tip, along the stem. + const tip = onCircle(cx, cy, r, to); + parts.push(smoothPath(leafAlong(tip, onCircle(cx, cy, r, to + dir * 12), 9, 0))); + return parts; +} + +/** A laurel wreath, open at the top, its two branches crossing at the foot. */ +const laurel = () => [...laurelBranch(1), ...laurelBranch(-1)].join(''); + +/** The Aldus leaf: a heart-shaped leaf on a stem that curls back on itself. */ +function fleuron(): string { + const leaf: Pt[] = []; + const turn = (-32 * Math.PI) / 180; + for (let i = 0; i < 48; i++) { + const t = (i / 48) * Math.PI * 2; + const hx = 16 * Math.sin(t) ** 3; + const hy = -(13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t)); + leaf.push([ + 96 + 3.2 * (hx * Math.cos(turn) - hy * Math.sin(turn)), + 62 + 3.2 * (hx * Math.sin(turn) + hy * Math.cos(turn)), + ]); + } + // The stem leaves from the notch of the heart, arches up and curls back. + const notch = leaf[0]; + const stem: Pt[] = [ + ...Array.from({ length: 14 }, (_, i) => { + const t = i / 13; + return [notch[0] + (36 - notch[0]) * t, notch[1] + (22 - notch[1]) * t - Math.sin(t * Math.PI) * 12] as Pt; + }), + ...spiral({ at: [26, 34], from: 12, to: 3, deg: 270, turns: 0.9, dir: -1 }).slice(1), + ]; + return smoothPath(clockwise(leaf), 0.15, new Set([0, 24])) + smoothPath(taper(stem, swell(6, 1.4)), 0.05); +} + +/** A corner: two arms along the edges from a curl in the corner, each ending in a small scroll. */ +function cornerFlourish(): string { + const arm = (flip: boolean): string[] => { + const p = ([a, b]: Pt): Pt => (flip ? [b, a] : [a, b]); + const line: Pt[] = []; + for (let i = 0; i <= 20; i++) { + const t = i / 20; + line.push(p([18 + t * 64, 10 + 5 * Math.sin(t * Math.PI)])); + } + const curl = spiral({ at: [90, 18], from: 8, to: 2.4, deg: 270, turns: 0.8, dir: 1 }).map(p); + const leaf = leafAlong(p([44, 13]), p([56, 27]), 6, 0.4); + return [smoothPath(taper([...line, ...curl.slice(1)], swell(4)), 0.05), smoothPath(leaf)]; + }; + const knot = spiral({ at: [17, 17], from: 10, to: 3, deg: 225, turns: 0.9, dir: 1 }); + return [...arm(false), ...arm(true), smoothPath(taper(knot, swell(4.4, 1.2)), 0.05)].join(''); +} + +export const ORNAMENT_PATHS = { + 'flourish-scroll': flourishScroll(), + laurel: laurel(), + fleuron: fleuron(), + 'corner-flourish': cornerFlourish(), +} as const; diff --git a/client/src/components/Studio/shapes.ts b/client/src/components/Studio/shapes.ts index f636e4560..ef229457e 100644 --- a/client/src/components/Studio/shapes.ts +++ b/client/src/components/Studio/shapes.ts @@ -1,4 +1,6 @@ import type { BookShapeId } from '@trek/shared' +import { K, circle, polygon, r2, roundRect, scallop, star } from './shapePrimitives' +import { MORE_SHAPE_PATHS } from './shapes-more' /** * Every shape Studio can draw, as a path on a 0..100 square. @@ -35,121 +37,6 @@ import type { BookShapeId } from '@trek/shared' * 0.03%, which is far below what a printer resolves. */ -/** Circle-to-cubic constant: the handle length that makes a quarter arc. */ -const K = 0.5523 - -const r2 = (n: number) => Math.round(n * 100) / 100 - -/** - * Stretch a ring of points to fill the 0..100 box exactly. - * - * A regular hexagon inscribed in a circle is 86.6 units wide and 100 tall, and - * drawn that way it would leave 7 units of nothing down each side of its own - * frame. In an editor that is a real problem rather than a cosmetic one: the - * selection outline, the resize handles and every snap target follow the frame, - * so a shape that does not fill its frame cannot be aligned to anything you can - * see. Filling the box is also what Canva does, and it costs a regularity - * nobody can spot — a hexagon in a square frame still reads as a hexagon. - */ -function fit(points: [number, number][]): string { - const xs = points.map(p => p[0]) - const ys = points.map(p => p[1]) - const minX = Math.min(...xs), maxX = Math.max(...xs) - const minY = Math.min(...ys), maxY = Math.max(...ys) - const sx = maxX > minX ? 100 / (maxX - minX) : 1 - const sy = maxY > minY ? 100 / (maxY - minY) : 1 - return `M${points - .map(([x, y]) => `${r2((x - minX) * sx)} ${r2((y - minY) * sy)}`) - .join('L')}Z` -} - -/** A regular polygon, first point at the top, stretched to fill the box. */ -function polygon(sides: number, rotation = -90): string { - const pts: [number, number][] = [] - for (let i = 0; i < sides; i++) { - const a = ((rotation + (360 / sides) * i) * Math.PI) / 180 - pts.push([50 + 50 * Math.cos(a), 50 + 50 * Math.sin(a)]) - } - return fit(pts) -} - -/** - * A star, `points` tips alternating with `points` valleys. - * - * `inner` is the valley radius as a fraction of the tip radius, and it is the - * only number that decides whether a star looks like a star or like a gear: - * roughly 0.38 for five points, higher as the count grows, or the tips get so - * thin they disappear at print size. - */ -function star(points: number, inner: number, rotation = -90): string { - const pts: [number, number][] = [] - for (let i = 0; i < points * 2; i++) { - const rad = i % 2 === 0 ? 50 : 50 * inner - const a = ((rotation + (180 / points) * i) * Math.PI) / 180 - pts.push([50 + rad * Math.cos(a), 50 + rad * Math.sin(a)]) - } - return fit(pts) -} - -/** - * A scalloped edge — a wax seal, a sticker, a burst. - * - * Not a star with a soft inner radius: the lobes have to bulge *outwards* - * between the points, which needs a curve through each valley rather than a - * straight line to it. - */ -function scallop(lobes: number, depth: number): string { - const step = (Math.PI * 2) / lobes - /* - * The petal tip is the curve's midpoint, not its control point. - * - * A quadratic at t=0.5 sits a quarter of the way to each end and half the way - * to the control, so the furthest the outline actually reaches is - * `0.5·R·cos(Δ/2) + 0.5·rm` — well short of `rm` itself. Sizing from `rm` - * would draw a flower that overflows its box by a tenth and gets clipped; - * solving for that midpoint instead makes the drawn petals touch the edge - * exactly, which is what "normalised to 0..100" has to mean. - */ - const reach = 0.5 * Math.cos(step / 2) + 0.5 * (1 + depth) - const R = 50 / reach - const rm = R * (1 + depth) - let d = '' - for (let i = 0; i < lobes; i++) { - const a0 = -Math.PI / 2 + step * i - const a1 = a0 + step - const am = (a0 + a1) / 2 - const x0 = r2(50 + R * Math.cos(a0)), y0 = r2(50 + R * Math.sin(a0)) - const x1 = r2(50 + R * Math.cos(a1)), y1 = r2(50 + R * Math.sin(a1)) - const xm = r2(50 + rm * Math.cos(am)), ym = r2(50 + rm * Math.sin(am)) - d += i === 0 ? `M${x0} ${y0}` : '' - d += `Q${xm} ${ym} ${x1} ${y1}` - } - return `${d}Z` -} - -/** A circle as four cubics. */ -function circle(cx = 50, cy = 50, rx = 50, ry = 50): string { - const kx = rx * K, ky = ry * K - return `M${r2(cx)} ${r2(cy - ry)}` - + `C${r2(cx + kx)} ${r2(cy - ry)} ${r2(cx + rx)} ${r2(cy - ky)} ${r2(cx + rx)} ${r2(cy)}` - + `C${r2(cx + rx)} ${r2(cy + ky)} ${r2(cx + kx)} ${r2(cy + ry)} ${r2(cx)} ${r2(cy + ry)}` - + `C${r2(cx - kx)} ${r2(cy + ry)} ${r2(cx - rx)} ${r2(cy + ky)} ${r2(cx - rx)} ${r2(cy)}` - + `C${r2(cx - rx)} ${r2(cy - ky)} ${r2(cx - kx)} ${r2(cy - ry)} ${r2(cx)} ${r2(cy - ry)}Z` -} - -/** A rounded rectangle, corner radius in the same 0..100 units. */ -function roundRect(r: number, x = 0, y = 0, w = 100, h = 100): string { - const k = r * (1 - K) - return `M${r2(x + r)} ${r2(y)}L${r2(x + w - r)} ${r2(y)}` - + `C${r2(x + w - k)} ${r2(y)} ${r2(x + w)} ${r2(y + k)} ${r2(x + w)} ${r2(y + r)}` - + `L${r2(x + w)} ${r2(y + h - r)}` - + `C${r2(x + w)} ${r2(y + h - k)} ${r2(x + w - k)} ${r2(y + h)} ${r2(x + w - r)} ${r2(y + h)}` - + `L${r2(x + r)} ${r2(y + h)}` - + `C${r2(x + k)} ${r2(y + h)} ${r2(x)} ${r2(y + h - k)} ${r2(x)} ${r2(y + h - r)}` - + `L${r2(x)} ${r2(y + r)}` - + `C${r2(x)} ${r2(y + k)} ${r2(x + k)} ${r2(y)} ${r2(x + r)} ${r2(y)}Z` -} - /** * The shape table. * @@ -284,6 +171,10 @@ export const SHAPE_PATHS: Record = { 'banner-pennant': 'M0 0L100 0L100 62L50 100L0 62Z', 'banner-bookmark': 'M0 0L100 0L100 100L50 68L0 100Z', 'banner-flag': 'M0 0L100 0L86 30L100 60L0 60Z', + + // The second shelf (shapes-more.ts): arches, cut-outs, stamps, ribbons, + // drawn arrows, hearts, frames and ornaments. + ...MORE_SHAPE_PATHS, } /** @@ -430,7 +321,7 @@ export function unitPath(path: string): string { * subpaths too, and under `evenodd` any overlap between them and the body * would knock a hole in it. */ -export const HOLED_SHAPES = new Set(['gear']) +export const HOLED_SHAPES = new Set(['gear', 'postmark', 'label-tab']) export interface ShapeGroup { /** i18n key under `journey.studio.shapeGroup`. */ @@ -457,16 +348,18 @@ export const SHAPE_GROUPS: ShapeGroup[] = [ }, { id: 'stars', - shapes: ['star-4', 'star-5', 'star-6', 'star-8', 'star-12', 'burst', 'seal', 'sparkle'], + shapes: ['star-4', 'star-5', 'star-6', 'star-8', 'star-12', 'burst', 'seal', 'sparkle', + 'star-rounded', 'star-twinkle'], }, { id: 'arrows', shapes: ['arrow-right', 'arrow-left', 'arrow-up', 'arrow-down', 'arrow-both', - 'chevron-right', 'chevron-left', 'arrow-bent'], + 'chevron-right', 'chevron-left', 'arrow-bent', 'arrow-curve', 'arrow-circle', 'arrow-notched', 'arrow-thin'], }, { id: 'speech', - shapes: ['bubble-round', 'bubble-square', 'bubble-oval', 'bubble-think'], + shapes: ['bubble-round', 'bubble-square', 'bubble-oval', 'bubble-think', + 'bubble-shout', 'bubble-side', 'bubble-capsule'], }, { id: 'travel', @@ -476,12 +369,27 @@ export const SHAPE_GROUPS: ShapeGroup[] = [ { id: 'decor', shapes: ['heart', 'flower-5', 'flower-6', 'cross', 'plus', 'gear', - 'blob-1', 'blob-2', 'blob-3', 'blob-4'], + 'blob-1', 'blob-2', 'blob-3', 'blob-4', 'heart-round', 'heart-pair', 'flower-8'], }, { id: 'banners', - shapes: ['banner-ribbon', 'banner-pennant', 'banner-bookmark', 'banner-flag'], + shapes: ['banner-ribbon', 'banner-pennant', 'banner-bookmark', 'banner-flag', + 'ribbon-ends', 'ribbon-arc', 'wavy-band', 'torn-strip'], }, + // The second shelf (shapes-more.ts). + { id: 'arches', shapes: ['arch-tall', 'arch-pointed', 'arch-double', 'arch-half', 'arch-rainbow'] }, + { + id: 'cutouts', + shapes: ['leaf-palm', 'leaf-oak', 'leaf-long', 'leaf-monstera', 'coral-1', 'coral-2', 'seaweed', + 'star-organic', 'sun-cutout', 'organic-1', 'organic-2', 'organic-3'], + }, + { + id: 'stamps', + shapes: ['stamp-rect', 'stamp-circle', 'postmark', 'rosette', 'scallop-circle', 'badge-shield', + 'label-tab', 'ticket-stub', 'sunburst-12', 'sunburst-24'], + }, + { id: 'frames', shapes: ['frame-notched', 'frame-chamfer', 'frame-cushion'] }, + { id: 'ornaments', shapes: ['flourish-scroll', 'laurel', 'fleuron', 'corner-flourish'] }, ] /** @@ -497,4 +405,9 @@ export const FRAME_SHAPES: BookShapeId[] = [ 'heart', 'star-5', 'star-6', 'seal', 'flower-5', 'flower-6', 'blob-1', 'blob-2', 'blob-3', 'blob-4', 'shield', 'drop', 'leaf', 'pin', 'banner-pennant', 'banner-bookmark', + // The second shelf: arches, stamps and the frames cut for photographs. + 'arch-tall', 'arch-pointed', 'arch-double', 'arch-half', 'stamp-rect', 'stamp-circle', + 'scallop-circle', 'badge-shield', 'ticket-stub', 'heart-round', 'star-rounded', 'flower-8', + 'frame-notched', 'frame-chamfer', 'frame-cushion', 'organic-1', 'organic-2', 'organic-3', + 'star-organic', 'sun-cutout', 'bubble-side', 'bubble-capsule', ] diff --git a/client/src/components/Studio/spreadFile.ts b/client/src/components/Studio/spreadFile.ts index 3ee22d9f9..67c9210b9 100644 --- a/client/src/components/Studio/spreadFile.ts +++ b/client/src/components/Studio/spreadFile.ts @@ -115,6 +115,7 @@ export function exportSpread(spread: BookSpread, page: BookPageSetup, name?: str if (typeof out.size === 'number') out.size = round4(out.size / PH) if (typeof out.radius === 'number') out.radius = round4(out.radius / PW) if (typeof out.strokeWidth === 'number') out.strokeWidth = round4(out.strokeWidth / PW) + if (typeof out.edgeSize === 'number') out.edgeSize = round4(out.edgeSize / PW) // The design, not the pictures. if (out.kind === 'photo') out.photoId = null /* @@ -168,6 +169,7 @@ export function importSpread(raw: unknown, page: BookPageSetup): BookSpread | nu if (typeof scaled.size === 'number') scaled.size = round2(scaled.size * PH) if (typeof scaled.radius === 'number') scaled.radius = round2(scaled.radius * PW) if (typeof scaled.strokeWidth === 'number') scaled.strokeWidth = round2(scaled.strokeWidth * PW) + if (typeof scaled.edgeSize === 'number') scaled.edgeSize = round2(scaled.edgeSize * PW) /* * A map arrives with no source, whatever the file says. * diff --git a/client/tests/unit/studio/lineElement.test.tsx b/client/tests/unit/studio/lineElement.test.tsx new file mode 100644 index 000000000..e82189963 --- /dev/null +++ b/client/tests/unit/studio/lineElement.test.tsx @@ -0,0 +1,198 @@ +import { + bookLineElementSchema, + bookLinePoints, + bookPageSetupSchema, + bookPhotoElementSchema, + bookShapeElementSchema, + type BookDocument, + type BookElement, + type BookLineElement, + type BookSpread, +} from '@trek/shared'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { LineHandles } from '../../../src/components/Studio/LineHandles'; +import { DIVIDER_PRESETS, LINE_PRESETS } from '../../../src/components/Studio/linePresets'; +import { constrainPoint } from '../../../src/components/Studio/lineSnap'; +import { SpreadView } from '../../../src/components/Studio/SpreadView'; +import { EdgeSection } from '../../../src/components/Studio/StudioEdgeControls'; +import { StudioInspector } from '../../../src/components/Studio/StudioInspector'; +import { StudioLineShelf } from '../../../src/components/Studio/StudioLineShelf'; +import { useStudioStore } from '../../../src/store/studioStore'; +import { fireEvent, render } from '../../helpers/render'; + +/** + * The drawn line and the cut edge in the Studio: drawn on the page, placed + * from the shelf, styled in the inspector and shaped by its handles. + */ + +const page = bookPageSetupSchema.parse({}); +const t = (k: string) => k; + +const line = (over: Partial = {}) => + bookLineElementSchema.parse({ id: 'l1', kind: 'line', frame: { x: 230, y: 100, w: 100, h: 4 }, ...over }); + +function spread(elements: BookElement[]): BookSpread { + return { id: 'sp1', role: 'inner', background: null, elements, parked: [], entryId: null }; +} + +function load(elements: BookElement[], selection = elements.slice(0, 1).map((e) => e.id)) { + useStudioStore.getState().load({ version: 1, title: 'T', page, spreads: [spread(elements)] } as BookDocument); + useStudioStore.getState().select(selection); +} + +const els = () => useStudioStore.getState().doc?.spreads[0].elements ?? []; +const first = () => els()[0] as BookLineElement; + +beforeEach(() => load([])); + +describe('on the page', () => { + it('FE-STUDIO-LINEEL-001: draws a line as a stroked body with its heads filled', () => { + const { container } = render( + + ); + const paths = [...container.querySelectorAll('svg path')]; + expect(paths).toHaveLength(2); + expect(paths[0].getAttribute('stroke')).toBe('#141414'); + expect(paths[0].getAttribute('stroke-dasharray')).toBe('1.5 1'); + expect(paths[1].getAttribute('fill')).toBe('#141414'); + }); + + it('FE-STUDIO-LINEEL-002: cuts a rectangle with an edge as a path, and clips a photo to its edge', () => { + const frame = { x: 10, y: 10, w: 60, h: 40 }; + const rect = bookShapeElementSchema.parse({ id: 's1', kind: 'shape', frame, edge: 'zigzag', edgeSize: 3 }); + const plain = bookShapeElementSchema.parse({ id: 's2', kind: 'shape', frame }); + const photo = bookPhotoElementSchema.parse({ id: 'p1', kind: 'photo', frame, photoId: 4, edge: 'perforated' }); + const { container } = render(); + const cut = container.querySelectorAll('svg path')[0]; + expect(cut.getAttribute('d')?.split('L').length).toBeGreaterThan(40); + expect(container.querySelector('#c-p1 path')?.getAttribute('d')).toMatch(/^M0 0L/); + }); +}); + +describe('the shelf', () => { + it('FE-STUDIO-LINEEL-003: offers every line and divider, each labelled, and places it on the right-hand page', () => { + const added: BookElement[] = []; + const { getByLabelText, container } = render( + added.push(el)} t={t} /> + ); + expect(container.querySelectorAll('.st-shape-btn')).toHaveLength(LINE_PRESETS.length + DIVIDER_PRESETS.length); + fireEvent.click(getByLabelText('journey.studio.linePreset.route')); + fireEvent.click(getByLabelText('journey.studio.linePreset.divider-sun')); + const [route, divider] = added as BookLineElement[]; + expect(route).toMatchObject({ kind: 'line', dash: 'dotted', curve: 'smooth', startCap: 'dot', endCap: 'arrow' }); + expect(route.points).toHaveLength(3); + expect(bookLinePoints(route)[0][0]).toBeCloseTo(262.5, 1); + expect(divider).toMatchObject({ ornament: 'sun', strokeWidth: 0.3 }); + }); + + it('FE-STUDIO-LINEEL-004: places a line on a single page from its own left edge', () => { + const added: BookElement[] = []; + const { getByLabelText } = render( added.push(el)} t={t} />); + fireEvent.click(getByLabelText('journey.studio.linePreset.straight')); + expect(bookLinePoints(added[0] as BookLineElement)[0][0]).toBeCloseTo(52.5, 1); + }); +}); + +describe('the inspector', () => { + const draw = () => + render( + {}} + t={t} + locale="en" + /> + ); + + it('FE-STUDIO-LINEEL-005: styles a line, its ends and its ornament, refitting the frame for a head', () => { + load([line()]); + const { getByText, getByTitle } = draw(); + fireEvent.click(getByText('journey.studio.strokeDashed')); + expect(first().dash).toBe('dashed'); + fireEvent.click(getByText('journey.studio.lineCourse.smooth')); + fireEvent.click(getByText('journey.studio.lineBody.wave')); + expect(first()).toMatchObject({ curve: 'smooth', squiggle: 'wave' }); + const before = first().frame.h; + fireEvent.click(document.querySelectorAll('[title="journey.studio.cap.arrow"]')[1] as HTMLElement); + expect(first().endCap).toBe('arrow'); + expect(first().frame.h).toBeGreaterThan(before); + fireEvent.click(getByText('journey.studio.swapEnds')); + expect(first()).toMatchObject({ startCap: 'arrow', endCap: 'none' }); + fireEvent.click(getByTitle('journey.studio.ornament.heart')); + expect(first().ornament).toBe('heart'); + }); + + it('FE-STUDIO-LINEEL-006: cuts an edge on a shape from its tiles and sets how deep it bites', () => { + const frame = { x: 10, y: 10, w: 60, h: 40 }; + load([bookShapeElementSchema.parse({ id: 's1', kind: 'shape', frame })]); + const { getByLabelText, container } = draw(); + const head = [...container.querySelectorAll('.st-section-head')].find( + (b) => b.textContent === 'journey.studio.edgeStyle' + ) as HTMLElement; + fireEvent.click(head); + fireEvent.click(getByLabelText('journey.studio.edge.perforated')); + expect(els()[0]).toMatchObject({ edge: 'perforated' }); + }); +}); + +describe('the edge controls', () => { + it('FE-STUDIO-LINEEL-007: stay away from a shape that takes no edge, and say so where one is set anyway', () => { + const set: unknown[] = []; + const none = render( + set.push(p)} t={t} /> + ); + expect(none.container.innerHTML).toBe(''); + const stray = render( set.push(p)} t={t} />); + expect(stray.getByText('journey.studio.edgeUnavailable')).toBeTruthy(); + fireEvent.click(stray.getByLabelText('journey.studio.edge.straight')); + expect(set).toEqual([{ edge: 'straight' }]); + const photo = render( set.push(p)} t={t} />); + const depth = photo.container.querySelector('.st-num input') as HTMLInputElement; + expect(depth.value).toBe('4'); + }); +}); + +describe('the handles', () => { + it('FE-STUDIO-LINEEL-008: holds a dragged end level or plumb near it, and to steps with shift', () => { + const level = constrainPoint([100, 1], [0, 0], false); + expect(level[1]).toBe(0); + expect(level[0]).toBeCloseTo(100, 1); + expect(constrainPoint([100, 30], [0, 0], false)).toEqual([100, 30]); + const stepped = constrainPoint([100, 30], [0, 0], true); + expect((Math.atan2(stepped[1], stepped[0]) * 180) / Math.PI).toBeCloseTo(15, 5); + expect(constrainPoint([5, 5], undefined, true)).toEqual([5, 5]); + }); + + it('FE-STUDIO-LINEEL-009: drags an end on the spread, adds a point from the middle and removes it again', () => { + load([line()]); + const draw = () => render(); + const { container, unmount } = draw(); + const ends = container.querySelectorAll('[title="journey.studio.addPoint"]'); + expect(ends).toHaveLength(1); + const handles = container.querySelectorAll('.st-handle'); + const end = handles[handles.length - 1] as HTMLElement; + fireEvent.pointerDown(end, { clientX: 0, clientY: 0, pointerId: 1 }); + fireEvent.pointerMove(end, { clientX: 20, clientY: 40, pointerId: 1 }); + fireEvent.pointerUp(end, { pointerId: 1 }); + const moved = bookLinePoints(first()); + expect(moved[1][0]).toBeCloseTo(340, 1); + expect(moved[1][1]).toBeCloseTo(122, 1); + unmount(); + + const again = draw(); + const mid = again.container.querySelector('[title="journey.studio.addPoint"]') as HTMLElement; + fireEvent.pointerDown(mid, { clientX: 0, clientY: 0, pointerId: 2 }); + fireEvent.pointerUp(mid, { pointerId: 2 }); + expect(first().points).toHaveLength(3); + again.unmount(); + + const third = draw(); + fireEvent.doubleClick(third.container.querySelector('[title="journey.studio.removePoint"]') as HTMLElement); + expect(first().points).toHaveLength(2); + }); +}); diff --git a/client/tests/unit/studio/lineRender.test.ts b/client/tests/unit/studio/lineRender.test.ts new file mode 100644 index 000000000..c22b23b69 --- /dev/null +++ b/client/tests/unit/studio/lineRender.test.ts @@ -0,0 +1,105 @@ +import { bookLineCapSize, bookLineElementSchema, type BookLineElement } from '@trek/shared'; +import { describe, expect, it } from 'vitest'; + +import { lineDash, lineGeometry } from '../../../src/components/Studio/lineRender'; + +/** + * A line as geometry (lineRender.ts): the body, its heads and its ornament, + * all as paths in the frame's millimetres. + */ + +const line = (over: Partial = {}) => + bookLineElementSchema.parse({ id: 'l1', kind: 'line', frame: { x: 0, y: 0, w: 100, h: 10 }, ...over }); + +const nums = (d: string) => (d.match(/-?\d*\.?\d+/g) ?? []).map(Number); +const xs = (d: string) => nums(d).filter((_, i) => i % 2 === 0); +const ys = (d: string) => nums(d).filter((_, i) => i % 2 === 1); + +describe('the body', () => { + it('FE-STUDIO-LINE-001: is one straight stroke from end to end for a plain line, butt ended', () => { + const g = lineGeometry(line(), 100, 10); + expect(g.body).toEqual(['M0 5L100 5']); + expect([g.fills, g.strokes, g.dash, g.cap]).toEqual([[], [], undefined, 'butt']); + }); + + it('FE-STUDIO-LINE-002: stops short of a filled head, so a thick line does not poke through its point', () => { + const g = lineGeometry(line({ endCap: 'arrow', strokeWidth: 1 }), 100, 10); + const size = bookLineCapSize(1); + expect(Math.max(...xs(g.body[0]))).toBeCloseTo(100 - size * 0.7, 1); + expect(g.fills).toHaveLength(1); + expect(Math.max(...xs(g.fills[0]))).toBeCloseTo(100, 1); + const open = lineGeometry(line({ startCap: 'open', endCap: 'bar' }), 100, 10); + expect(open.strokes).toHaveLength(2); + expect(Math.min(...xs(open.body[0]))).toBeCloseTo(0.25, 2); + const dot = lineGeometry(line({ startCap: 'dot' }), 100, 10); + expect(dot.fills[0]).toMatch(/^M0 4.2C/); + }); + + it('FE-STUDIO-LINE-003: opens in the middle for an ornament and sets it there', () => { + for (const ornament of ['diamond', 'dot', 'star', 'sun', 'heart'] as const) { + const g = lineGeometry(line({ ornament, strokeWidth: 0.3 }), 100, 10); + expect(g.body, ornament).toHaveLength(2); + expect(g.fills, ornament).toHaveLength(1); + const mid = xs(g.fills[0]); + expect((Math.min(...mid) + Math.max(...mid)) / 2, ornament).toBeCloseTo(50, 0); + expect(Math.max(...xs(g.body[0]))).toBeLessThan(Math.min(...mid)); + } + }); + + it('FE-STUDIO-LINE-004: lays a wave or a zigzag along it, calm at the ends', () => { + const wave = lineGeometry(line({ squiggle: 'wave' }), 100, 10); + const wy = ys(wave.body[0]); + expect(Math.max(...wy) - Math.min(...wy)).toBeGreaterThan(1.5); + expect(wy[0]).toBeCloseTo(5, 2); + expect(wy[wy.length - 1]).toBeCloseTo(5, 2); + const zig = lineGeometry(line({ squiggle: 'zigzag' }), 100, 10); + expect(new Set(ys(zig.body[0]).map((v) => Math.round(v * 10) / 10))).toEqual(new Set([3.9, 5, 6.1])); + }); + + it('FE-STUDIO-LINE-005: wobbles as if drawn by hand, the same way every time for one element, held at its ends', () => { + const a = lineGeometry(line({ wobble: 1 }), 100, 10, 'l-a'); + expect(lineGeometry(line({ wobble: 1 }), 100, 10, 'l-a')).toEqual(a); + expect(lineGeometry(line({ wobble: 1 }), 100, 10, 'l-b').body).not.toEqual(a.body); + expect(a.cap).toBe('round'); + const y = ys(a.body[0]); + expect(y[0]).toBeCloseTo(5, 2); + expect(Math.max(...y.map((v) => Math.abs(v - 5)))).toBeGreaterThan(0.05); + }); + + it('FE-STUDIO-LINE-006: runs a smooth curve through more than two points', () => { + const points = [ + { x: 0, y: 1 }, + { x: 0.5, y: 0 }, + { x: 1, y: 1 }, + ]; + const g = lineGeometry(line({ points, curve: 'smooth' }), 100, 10); + expect(nums(g.body[0]).length).toBeGreaterThan(40); + const straight = lineGeometry(line({ points }), 100, 10); + expect(straight.body).toEqual(['M0 10L50 0L100 10']); + }); + + it('FE-STUDIO-LINE-007: draws nothing for a line with no length', () => { + const g = lineGeometry( + line({ + points: [ + { x: 0.5, y: 0.5 }, + { x: 0.5, y: 0.5 }, + ], + endCap: 'arrow', + }), + 100, + 10 + ); + expect([g.body, g.fills]).toEqual([[], []]); + }); +}); + +describe('the dash', () => { + it('FE-STUDIO-LINE-008: follows the width and stays visible on a hairline', () => { + expect(lineDash('solid', 1)).toBeUndefined(); + expect(lineDash('dashed', 1)).toBe('3 2'); + expect(lineDash('dashed', 0.1)).toBe('1.2 0.9'); + expect(lineDash('dotted', 0.5)).toBe('0.01 1'); + expect(lineGeometry(line({ dash: 'dotted' }), 100, 10).cap).toBe('round'); + }); +}); diff --git a/client/tests/unit/studio/shapeEdges.test.ts b/client/tests/unit/studio/shapeEdges.test.ts new file mode 100644 index 000000000..197eb9f39 --- /dev/null +++ b/client/tests/unit/studio/shapeEdges.test.ts @@ -0,0 +1,102 @@ +import { BOOK_EDGES } from '@trek/shared'; +import { describe, expect, it } from 'vitest'; + +import { SHAPE_PATHS, scalePath } from '../../../src/components/Studio/shapes'; +import { edgedPath, flattenPath, outlineRuns } from '../../../src/components/Studio/shapes-edges'; + +/** + * Cut edges (shapes-edges.ts): the outline flattened, split at its corners, + * a whole number of teeth per side, cut inwards only. + */ + +const RECT = 'M0 0L60 0L60 40L0 40Z'; +const points = (d: string) => { + const nums = (d.match(/-?\d*\.?\d+/g) ?? []).map(Number); + const out: [number, number][] = []; + for (let i = 0; i < nums.length; i += 2) out.push([nums[i], nums[i + 1]]); + return out; +}; + +describe('the outline', () => { + it('FE-STUDIO-EDGE-001: flattens lines and curves, dropping the closing repeat', () => { + expect(flattenPath(RECT)).toEqual([ + [0, 0], + [60, 0], + [60, 40], + [0, 40], + ]); + const circle = flattenPath(scalePath(SHAPE_PATHS.ellipse, 50, 50)); + expect(circle.length).toBeGreaterThan(30); + for (const [x, y] of circle) expect(Math.hypot(x - 25, y - 25)).toBeCloseTo(25, 0); + expect(flattenPath('M0 0Q10 10 20 0Z').length).toBeGreaterThan(3); + }); + + it('FE-STUDIO-EDGE-002: splits a rectangle into four runs at its corners and a circle into one', () => { + expect(outlineRuns(flattenPath(RECT))).toHaveLength(4); + expect(outlineRuns(flattenPath(scalePath(SHAPE_PATHS.ellipse, 50, 50)))).toHaveLength(1); + expect(outlineRuns(flattenPath(scalePath(SHAPE_PATHS.hexagon, 50, 50)))).toHaveLength(6); + }); +}); + +describe('the cut', () => { + it('FE-STUDIO-EDGE-003: leaves a straight edge and a degenerate outline as they are', () => { + expect(edgedPath(RECT, 'straight', 3)).toBe(RECT); + expect(edgedPath(RECT, 'zigzag', 0)).toBe(RECT); + expect(edgedPath('M0 0L10 0Z', 'zigzag', 3)).toBe('M0 0L10 0Z'); + }); + + it('FE-STUDIO-EDGE-004: cuts every edge inwards, inside the frame, with the corners left on the outline', () => { + for (const edge of BOOK_EDGES.filter((e) => e !== 'straight')) { + const d = edgedPath(RECT, edge, 3, 5); + expect(d, edge).toMatch(/^M[\d.]+ [\d.]+(L[\d.]+ [\d.]+)+Z$/); + const pts = points(d); + for (const [x, y] of pts) { + expect(x, edge).toBeGreaterThanOrEqual(-0.01); + expect(x, edge).toBeLessThanOrEqual(60.01); + expect(y, edge).toBeGreaterThanOrEqual(-0.01); + expect(y, edge).toBeLessThanOrEqual(40.01); + } + for (const corner of [ + [0, 0], + [60, 0], + [60, 40], + [0, 40], + ]) { + expect( + pts.some(([x, y]) => Math.hypot(x - corner[0], y - corner[1]) < 0.02), + `${edge} ${corner}` + ).toBe(true); + } + } + }); + + it('FE-STUDIO-EDGE-005: gives each side a whole number of teeth, so a stamp has round holes all round', () => { + const zig = points(edgedPath(RECT, 'zigzag', 3)); + // 20 teeth along 60 mm and 13 along 40 mm, two points each. + expect(zig).toHaveLength((20 + 13) * 2 * 2); + // How deep the top side bites, its corners left out. + const top = (pts: [number, number][]) => + Math.max(...pts.filter(([x, y]) => x > 3 && x < 57 && y < 10).map(([, y]) => y)); + expect(top(zig)).toBeCloseTo(1.5, 1); + expect(top(points(edgedPath(RECT, 'perforated', 4)))).toBeCloseTo(1.2, 1); + }); + + it('FE-STUDIO-EDGE-006: tears the same way for the same seed and another way for another', () => { + expect(edgedPath(RECT, 'torn', 3, 7)).toBe(edgedPath(RECT, 'torn', 3, 7)); + expect(edgedPath(RECT, 'torn', 3, 7)).not.toBe(edgedPath(RECT, 'torn', 3, 8)); + expect(edgedPath(RECT, 'deckle', 3, 7)).not.toBe(edgedPath(RECT, 'deckle', 3, 8)); + }); + + it('FE-STUDIO-EDGE-007: coarsens the teeth past the point cap rather than drawing a path nobody can render', () => { + const huge = 'M0 0L5000 0L5000 5000L0 5000Z'; + expect(points(edgedPath(huge, 'scallop', 0.5)).length).toBeLessThanOrEqual(3100); + }); + + it('FE-STUDIO-EDGE-008: cuts inwards whichever way the outline is wound', () => { + const reversed = edgedPath('M0 0L0 40L60 40L60 0Z', 'zigzag', 3); + for (const [x, y] of points(reversed)) { + expect(x).toBeGreaterThanOrEqual(-0.01); + expect(y).toBeLessThanOrEqual(40.01); + } + }); +}); diff --git a/client/tests/unit/studio/shapesMore.test.ts b/client/tests/unit/studio/shapesMore.test.ts new file mode 100644 index 000000000..b5ee6cc2d --- /dev/null +++ b/client/tests/unit/studio/shapesMore.test.ts @@ -0,0 +1,206 @@ +import { BOOK_SHAPE_ASPECT, BOOK_SHAPES_MORE, bookPhotoElementSchema, bookShapeElementSchema } from '@trek/shared'; +import { describe, expect, it } from 'vitest'; + +import { + anticlockwise, + arcTo, + clockwise, + leafAlong, + seeded, + seedOf, + signedArea, + smoothPath, + taper, +} from '../../../src/components/Studio/shapeDraw'; +import { + DEFAULT_EDGE_SIZE, + hasEdge, + photoClip, + shapeOutline, + shapeTile, +} from '../../../src/components/Studio/shapeOutline'; +import { FRAME_SHAPES, SHAPE_GROUPS, SHAPE_PATHS } from '../../../src/components/Studio/shapes'; +import { MORE_SHAPE_PATHS } from '../../../src/components/Studio/shapes-more'; + +/** + * The second shelf of shapes and the tools they are drawn with. + * + * shapes.test.ts already holds every shape to the path subset, the 0..100 box + * and the winding rule; this pins what is new: the proportions recorded in the + * shared contract match the drawings, the tiles are drawn at them, the + * hand-cut shapes come out the same every time, and the outline a shape or a + * photo is drawn with picks up its edge only where an edge is cut. + */ + +/** The box a raw path's ink occupies, curves sampled. */ +function bounds(path: string) { + const nums = (path.match(/-?\d*\.?\d+/g) ?? []).map(Number); + const cmds = path.match(/[MLCQZ]/g) ?? []; + let i = 0; + let cx = 0; + let cy = 0; + let x0 = Infinity; + let y0 = Infinity; + let x1 = -Infinity; + let y1 = -Infinity; + const hit = (x: number, y: number) => { + x0 = Math.min(x0, x); + x1 = Math.max(x1, x); + y0 = Math.min(y0, y); + y1 = Math.max(y1, y); + }; + for (const c of cmds) { + if (c === 'Z') continue; + if (c === 'M' || c === 'L') { + cx = nums[i++]; + cy = nums[i++]; + hit(cx, cy); + continue; + } + const k = c === 'Q' ? 4 : 6; + const a = nums.slice(i, i + k); + i += k; + for (let t = 0; t <= 1.0001; t += 0.02) { + const u = 1 - t; + if (c === 'Q') hit(u * u * cx + 2 * u * t * a[0] + t * t * a[2], u * u * cy + 2 * u * t * a[1] + t * t * a[3]); + else { + hit( + u * u * u * cx + 3 * u * u * t * a[0] + 3 * u * t * t * a[2] + t * t * t * a[4], + u * u * u * cy + 3 * u * u * t * a[1] + 3 * u * t * t * a[3] + t * t * t * a[5] + ); + } + } + cx = a[k - 2]; + cy = a[k - 1]; + } + return { w: x1 - x0, h: y1 - y0 }; +} + +describe('the second shelf', () => { + it('FE-STUDIO-SHAPES2-001: draws every id the contract lists, at the proportions the contract records', () => { + const off: string[] = []; + for (const id of BOOK_SHAPES_MORE) { + expect(MORE_SHAPE_PATHS[id], id).toMatch(/^M/); + const b = bounds(MORE_SHAPE_PATHS[id]); + const drawn = b.w / b.h; + if (Math.abs(drawn - BOOK_SHAPE_ASPECT[id]) > 0.03) off.push(`${id}: ${drawn.toFixed(2)}`); + } + expect(off).toEqual([]); + }); + + it('FE-STUDIO-SHAPES2-002: groups every new shape once, and offers the broad ones as photo frames', () => { + const grouped = SHAPE_GROUPS.flatMap((g) => g.shapes); + for (const id of BOOK_SHAPES_MORE) + expect( + grouped.filter((s) => s === id), + id + ).toHaveLength(1); + for (const id of ['arch-tall', 'stamp-rect', 'frame-notched', 'organic-1'] as const) + expect(FRAME_SHAPES).toContain(id); + for (const id of ['laurel', 'coral-1', 'postmark', 'arrow-thin'] as const) expect(FRAME_SHAPES).not.toContain(id); + }); + + it('FE-STUDIO-SHAPES2-003: draws a tile at the shape proportions, and a first-shelf shape in its square', () => { + const scroll = shapeTile('flourish-scroll'); + expect(scroll.w).toBe(100); + expect(scroll.h).toBeCloseTo(100 / BOOK_SHAPE_ASPECT['flourish-scroll'], 5); + const arch = shapeTile('arch-tall'); + expect([arch.w, arch.h]).toEqual([50, 100]); + expect(shapeTile('heart')).toEqual({ d: SHAPE_PATHS.heart, w: 100, h: 100 }); + }); +}); + +describe('the drawing tools', () => { + it('FE-STUDIO-SHAPES2-004: winds a ring either way on request and measures it with the sign the tests read', () => { + const square: [number, number][] = [ + [0, 0], + [10, 0], + [10, 10], + [0, 10], + ]; + expect(signedArea(square)).toBe(100); + expect(signedArea(clockwise([...square].reverse()))).toBe(100); + expect(signedArea(anticlockwise(square))).toBe(-100); + }); + + it('FE-STUDIO-SHAPES2-005: tears and seeds the same way every time', () => { + const a = seeded(seedOf('s-abc')); + const b = seeded(seedOf('s-abc')); + const run = [a(), a(), a()]; + expect([b(), b(), b()]).toEqual(run); + for (const v of run) expect(v).toBeGreaterThanOrEqual(0); + expect(seedOf('a')).not.toBe(seedOf('b')); + }); + + it('FE-STUDIO-SHAPES2-006: draws smooth outlines, tapered strokes, leaves and arcs in the path subset', () => { + const ring: [number, number][] = [ + [0, 0], + [10, 0], + [10, 10], + ]; + expect(smoothPath(ring)).toMatch(/^M0 0(C[-\d. ]+){3}Z$/); + expect(smoothPath(ring, 0.5, new Set([1]))).toContain('C10 0 '); + const stroke = taper( + [ + [0, 0], + [10, 0], + [20, 0], + ], + () => 2 + ); + expect(stroke).toHaveLength(6); + expect(signedArea(stroke)).toBeGreaterThan(0); + const pointed = taper( + [ + [0, 0], + [10, 0], + ], + (t) => (t === 0 ? 0 : 2) + ); + expect(pointed).toHaveLength(3); + const tipped = taper( + [ + [0, 0], + [10, 0], + ], + (t) => (t === 1 ? 0 : 2) + ); + expect(tipped).toHaveLength(3); + expect(signedArea(leafAlong([0, 0], [20, 0], 6, 0.4))).toBeGreaterThan(0); + expect(arcTo(0, 0, 10, 0, 180).match(/C/g)).toHaveLength(2); + }); +}); + +describe('the outline a shape is drawn with', () => { + it('FE-STUDIO-SHAPES2-007: cuts an edge along the shapes that take one and keeps the drawn outline elsewhere', () => { + const frame = { x: 0, y: 0, w: 60, h: 40 }; + const rect = bookShapeElementSchema.parse({ id: 's1', kind: 'shape', frame, edge: 'zigzag' }); + const heart = bookShapeElementSchema.parse({ id: 's2', kind: 'shape', frame, shape: 'heart', edge: 'zigzag' }); + expect(hasEdge(rect, rect.shape)).toBe(true); + expect(hasEdge(heart, heart.shape)).toBe(false); + expect(hasEdge({ edge: 'straight' }, 'rect')).toBe(false); + expect(shapeOutline(rect, 60, 40).match(/L/g)?.length).toBeGreaterThan(20); + expect(shapeOutline(heart, 60, 40)).not.toMatch(/L.*L.*L.*L.*L.*L.*L.*L.*L/); + const rounded = shapeOutline({ ...rect, radius: 8, edge: 'wavy' }, 60, 40); + expect(rounded).toMatch(/^M[\d.]+ [\d.]+L/); + expect(DEFAULT_EDGE_SIZE).toBe(3); + }); + + it('FE-STUDIO-SHAPES2-008: gives a photo a 0..1 clip for a mask or an edge, and none for the plain rectangle', () => { + const frame = { x: 0, y: 0, w: 60, h: 40 }; + const plain = bookPhotoElementSchema.parse({ id: 'p1', kind: 'photo', frame }); + expect(photoClip(plain, 60, 40)).toBeNull(); + expect(photoClip({ ...plain, mask: 'rect' }, 60, 40)).toBeNull(); + const masked = photoClip({ ...plain, mask: 'arch-tall' }, 60, 40); + const stamp = photoClip({ ...plain, edge: 'perforated', edgeSize: 2 }, 60, 40); + for (const clip of [masked, stamp]) { + const nums = (clip?.match(/-?\d*\.?\d+/g) ?? []).map(Number); + expect(nums.length).toBeGreaterThan(4); + expect(Math.min(...nums)).toBeGreaterThanOrEqual(-0.001); + expect(Math.max(...nums)).toBeLessThanOrEqual(1.001); + } + expect(photoClip({ ...plain, edge: 'perforated' }, 0, 40)).toBeNull(); + const edgedArch = photoClip({ ...plain, mask: 'arch-tall', edge: 'scallop' }, 60, 40); + expect(edgedArch).not.toBe(masked); + }); +}); diff --git a/server/src/nest/journey/journey-book.mcp.ts b/server/src/nest/journey/journey-book.mcp.ts index 88180319e..70006800a 100644 --- a/server/src/nest/journey/journey-book.mcp.ts +++ b/server/src/nest/journey/journey-book.mcp.ts @@ -192,7 +192,7 @@ export class JourneyBookMcp { @Tool({ name: 'edit_book_page', - description: `Change one page with 1 to ${BOOK_OP_LIMITS.opsPerCall} operations, run in order, all or nothing: layout (a template, grid, saved layout, or auto to lay the page out again from its entry), place_photo (a photo of the journey's gallery into a frame by elementId, by its slot among the page's frames, or into a new frame), photo_look (the crop as fit and focal point, mask, frame style, filter, radius), swap_photos, text (by elementId, or by role heading, body or meta, which creates the text when the page has none), caption (under a photo, bound to its caption in the journal unless words are given), frame (move, resize, turn), remove (parked with the page by default, park false deletes), background (a colour, or a paint: a colour with alpha, a linear or radial gradient, or one of twelve patterns; null is the paper), add (a new photo frame, text, shape, icon, sticker from list_book_assets, map, figures, country outlines, mark or list; the journey's own values such as the route, figures and countries come from the journey, never from you), style (the look of one element of its kind: opacity, colours, a shape's paint, fonts, strokes and the like, and for a photo its border, hard shadow, zoom, mirror, adjustments, duotone and cutout), arrange (to front, forward, backward, to back), align and distribute (several elements by their edges or centres, against each other, the page or the safe area), lock, duplicate, fill (a photo to the bleed of its page or the spread, behind everything), copy_from (elements of another page), refresh_travel (travel elements to the journey's current figures), add_grid (a photo grid as empty frames in a box), group and ungroup (two or more elements picked, moved, aligned and arranged as one, such as a title lockup; align and distribute move a group as one box), name (an element or a group, as the layers panel shows it) and hide (left off the page and out of print, or shown again). A locked element refuses any operation that would change it but lock, ungroup, name and hide. The road a map draws between stops is a straight or bowed line; roads are not routed. ${LAYOUT_NOTES.templates} ${LAYOUT_NOTES.grids} ${UNITS} With returnPrevious the answer also holds the page as it was, under previous_spreads, and a digest of each page as the write left it under page_digests (for restore_book_page expectedPages). ${WRITE_ANSWER}`, + description: `Change one page with 1 to ${BOOK_OP_LIMITS.opsPerCall} operations, run in order, all or nothing: layout (a template, grid, saved layout, or auto to lay the page out again from its entry), place_photo (a photo of the journey's gallery into a frame by elementId, by its slot among the page's frames, or into a new frame), photo_look (the crop as fit and focal point, mask, frame style, filter, radius), swap_photos, text (by elementId, or by role heading, body or meta, which creates the text when the page has none), caption (under a photo, bound to its caption in the journal unless words are given), frame (move, resize, turn), remove (parked with the page by default, park false deletes), background (a colour, or a paint: a colour with alpha, a linear or radial gradient, or one of twelve patterns; null is the paper), add (a new photo frame, text, shape, icon, sticker from list_book_assets, line, map, figures, country outlines, mark or list; a line runs through points on the spread in mm, with heads, dashes, a wave or an ornament; the journey's own values such as the route, figures and countries come from the journey, never from you), style (the look of one element of its kind: opacity, colours, a shape's paint, fonts, strokes, a cut edge on a shape or photo such as a stamp's perforation, a line's points and the like, and for a photo its border, hard shadow, zoom, mirror, adjustments, duotone and cutout), arrange (to front, forward, backward, to back), align and distribute (several elements by their edges or centres, against each other, the page or the safe area), lock, duplicate, fill (a photo to the bleed of its page or the spread, behind everything), copy_from (elements of another page), refresh_travel (travel elements to the journey's current figures), add_grid (a photo grid as empty frames in a box), group and ungroup (two or more elements picked, moved, aligned and arranged as one, such as a title lockup; align and distribute move a group as one box), name (an element or a group, as the layers panel shows it) and hide (left off the page and out of print, or shown again). A locked element refuses any operation that would change it but lock, ungroup, name and hide. The road a map draws between stops is a straight or bowed line; roads are not routed. ${LAYOUT_NOTES.templates} ${LAYOUT_NOTES.grids} ${UNITS} With returnPrevious the answer also holds the page as it was, under previous_spreads, and a digest of each page as the write left it under page_digests (for restore_book_page expectedPages). ${WRITE_ANSWER}`, inputSchema: { journeyId: idSchema, ...editBookPageArgs }, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, when: journeyAddonOn, diff --git a/shared/src/book/book-element-ops.schema.ts b/shared/src/book/book-element-ops.schema.ts index 82c846cc7..5ee8d54a1 100644 --- a/shared/src/book/book-element-ops.schema.ts +++ b/shared/src/book/book-element-ops.schema.ts @@ -1,4 +1,5 @@ import { idSchema } from '../common/primitives.schema'; +import { BOOK_EDGE_PROPS, LINE_PROPS } from './book-line-ops.schema'; import { BOOK_BINDING_HINT, bookBadgeStyleSchema, @@ -100,6 +101,7 @@ const PHOTO_PROPS = { frameStyle: bookFrameStyleSchema.optional(), filter: bookPhotoFilterSchema.optional(), radius: z.number().finite().min(0).max(500).optional(), + ...BOOK_EDGE_PROPS, }; /** How a text is set, each field left as it is when not named: the style op's and the page edit's text op's. */ export const TEXT_STYLE_FIELDS = { @@ -123,6 +125,7 @@ const SHAPE_PROPS = { strokeWidth: z.number().finite().min(0).max(20).optional(), strokeStyle: bookStrokeStyleSchema.optional(), radius: z.number().finite().min(0).max(500).optional(), + ...BOOK_EDGE_PROPS, }; const ICON_PROPS = { ...COMMON, @@ -208,6 +211,7 @@ export const bookElementPropsSchema = z.discriminatedUnion('kind', [ z.strictObject({ kind: z.literal('countries'), ...COUNTRIES_PROPS }), z.strictObject({ kind: z.literal('badge'), ...BADGE_PROPS }), z.strictObject({ kind: z.literal('list'), ...LIST_PROPS }), + z.strictObject({ kind: z.literal('line'), ...LINE_PROPS }), ]); export type BookElementProps = z.infer; @@ -301,6 +305,7 @@ export const bookNewElementSchema = z.discriminatedUnion('kind', [ .refine((v) => (v.entryId === undefined) !== (v.items === undefined), { message: 'give an entryId or the items, not both', }), + z.strictObject({ kind: z.literal('line'), ...LINE_PROPS }), ]); export type BookNewElement = z.infer; diff --git a/shared/src/book/book-findings-design.ts b/shared/src/book/book-findings-design.ts index 9daba17df..c93bd7078 100644 --- a/shared/src/book/book-findings-design.ts +++ b/shared/src/book/book-findings-design.ts @@ -194,7 +194,8 @@ function pagesOf(ctx: Ctx): Box[] { return [first, { x: w, y: 0, w, h }]; } -const isContent = (el: BookElement) => el.kind !== 'shape' || el.frame.w > 2 || el.frame.h > 2; +// A drawn line is a rule however long it runs, so it never counts as content. +const isContent = (el: BookElement) => el.kind !== 'line' && (el.kind !== 'shape' || el.frame.w > 2 || el.frame.h > 2); /** How much of a page's live area (inside the safe margin) is covered, rastered in 4 mm cells. */ function coverage(page: Box, safe: number, els: readonly BookElement[]): number { @@ -265,7 +266,9 @@ function offGrid(ctx: Ctx): void { const baseX = [-bleed, 0, safe, W - safe, W, W + bleed, ...(inner ? [PW, PW - safe, PW + safe] : [])]; const baseY = [-bleed, 0, safe, H - safe, H, H + bleed]; // Shapes and stickers are laid freely; only the content is held to the grid. - const content = ctx.spread.elements.filter((e) => e.kind !== 'shape' && e.kind !== 'sticker' && !e.rotation); + const content = ctx.spread.elements.filter( + (e) => e.kind !== 'shape' && e.kind !== 'sticker' && e.kind !== 'line' && !e.rotation, + ); const near = (v: number, lines: number[]) => lines.some((l) => Math.abs(v - l) > 0.5 && Math.abs(v - l) < 3); const onLine = (v: number, lines: number[]) => lines.some((l) => Math.abs(v - l) <= 0.5); const misses = (edges: number[], lines: number[]) => edges.some((v) => near(v, lines) && !onLine(v, lines)); diff --git a/shared/src/book/book-findings-lines.ts b/shared/src/book/book-findings-lines.ts new file mode 100644 index 000000000..6770b8b8a --- /dev/null +++ b/shared/src/book/book-findings-lines.ts @@ -0,0 +1,49 @@ +import type { BookFinding } from './book-ops.schema'; +import { bookShapeTakesEdge } from './book-shapes-more'; +import type { BookElement, BookSpread } from './book.schema'; + +/** + * The print checks of lines, outlines and cut edges, for spreadFindings. + * + * `hairline`: a line or an outline under a tenth of a millimetre. A press + * holds about that much; below it the line breaks up or disappears, and on + * screen at any zoom it still looks fine, which is why nobody notices. + * + * `edge_unused`: a cut edge set on a shape it is not cut along (a heart, a + * star). The renderer draws the plain outline, so the setting does nothing, + * and a model that set it should know. + */ + +/** The thinnest line a print shop holds, in millimetres. */ +const BOOK_HAIRLINE_MM = 0.1; + +const mm = (v: number) => `${Math.round(v * 100) / 100}`; + +function hairline(el: BookElement): BookFinding | null { + const width = el.kind === 'line' ? el.strokeWidth : el.kind === 'shape' && el.stroke ? el.strokeWidth : null; + if (width === null || width <= 0 || width >= BOOK_HAIRLINE_MM) return null; + return { + code: 'hairline', + severity: 'warn', + elementIds: [el.id], + message: `${el.kind} ${el.id} is drawn ${mm(width)} mm thin; under ${BOOK_HAIRLINE_MM} mm a line may not print`, + }; +} + +function edgeUnused(el: BookElement): BookFinding | null { + if (el.kind !== 'shape' && el.kind !== 'photo') return null; + if (!el.edge || el.edge === 'straight') return null; + const outline = el.kind === 'shape' ? el.shape : el.mask; + if (bookShapeTakesEdge(outline)) return null; + return { + code: 'edge_unused', + severity: 'info', + elementIds: [el.id], + message: `${el.kind} ${el.id} has a ${el.edge} edge, but ${outline} is not cut along one; it draws its plain outline`, + }; +} + +/** Every line, outline and edge finding of one page, in paint order. */ +export function strokeFindings(spread: BookSpread): BookFinding[] { + return spread.elements.flatMap((el) => [hairline(el), edgeUnused(el)].filter((f): f is BookFinding => f !== null)); +} diff --git a/shared/src/book/book-findings.ts b/shared/src/book/book-findings.ts index 43701643f..46be4d59d 100644 --- a/shared/src/book/book-findings.ts +++ b/shared/src/book/book-findings.ts @@ -1,5 +1,6 @@ import { bookVisiblePart, sortBookFindings } from './book-findings-design'; import { photoLookChecks } from './book-findings-photo'; +import { strokeFindings } from './book-findings-lines'; import { stickerFindings } from './book-findings-sticker'; import { bookFontFace } from './book-fonts'; import { visibleSpread } from './book-groups'; @@ -510,5 +511,6 @@ export function spreadFindings( ctx.out.push(...photoLookChecks(spread, page)); parked(ctx); for (const f of stickerFindings(spread)) add(ctx, f); + for (const f of strokeFindings(spread)) add(ctx, f); return sortBookFindings(ctx.out); } diff --git a/shared/src/book/book-line-ops.schema.ts b/shared/src/book/book-line-ops.schema.ts new file mode 100644 index 000000000..ca447158a --- /dev/null +++ b/shared/src/book/book-line-ops.schema.ts @@ -0,0 +1,62 @@ +import { BOOK_LINE_CAPS, BOOK_LINE_ORNAMENTS, MAX_LINE_POINTS } from './book-line.schema'; +import { BOOK_EDGES, BOOK_EDGE_SIZE_MAX, BOOK_EDGE_SIZE_MIN } from './book-shapes-more'; + +import { z } from 'zod'; + +/** + * What the book tools take for the second shelf of shapes: the cut edge a + * shape or a photo can be given, and the drawn line. Joined into the props and + * the new-element union of book-element-ops.schema.ts, so `add` and `style` + * reach them as they reach every other kind. + * + * Each enum says in one line what its values draw, since the tool schema is + * all a model reads of them (book-op-enums.ts has the rest). + */ + +const mmIn = z.number().finite().min(-10000).max(10000); + +export const bookEdgeSchema = z + .enum(BOOK_EDGES) + .describe( + 'A cut outline: zigzag, scallop, perforated (a stamp), wavy, torn, deckle (handmade paper); straight keeps it.', + ); + +/** The edge of a shape or a photo, each left as it is when not named. */ +export const BOOK_EDGE_PROPS = { + edge: bookEdgeSchema.optional(), + edgeSize: z + .number() + .finite() + .min(BOOK_EDGE_SIZE_MIN) + .max(BOOK_EDGE_SIZE_MAX) + .describe('Depth of the cut in mm, 2 to 4 for a stamp.') + .optional(), +}; + +const capSchema = z.enum(BOOK_LINE_CAPS).describe('An end: arrow, open (a chevron), dot, bar or none.'); + +/** Points on the spread, in millimetres, from the first end to the last. */ +export const bookLinePointsInSchema = z + .array(z.strictObject({ x: mmIn, y: mmIn })) + .min(2) + .max(MAX_LINE_POINTS) + .describe('Points on the spread in mm, start to end.'); + +/** How a line is drawn, each field left as it is when not named: the style op's, and the look of a new one. */ +export const LINE_PROPS = { + opacity: z.number().min(0).max(1).optional(), + stroke: z + .string() + .regex(/^#[0-9a-fA-F]{6}$/, 'expected #rrggbb') + .optional(), + strokeWidth: z.number().finite().min(0.05).max(20).describe('Weight in mm.').optional(), + dash: z.enum(['solid', 'dashed', 'dotted']).optional(), + curve: z.enum(['straight', 'smooth']).describe('smooth is one curve through the points.').optional(), + squiggle: z.enum(['none', 'wave', 'zigzag']).describe('The body drawn as a wave or a zigzag.').optional(), + startCap: capSchema.optional(), + endCap: capSchema.optional(), + wobble: z.number().min(0).max(1).describe('Hand-drawn waver, 0 to 1.').optional(), + ornament: z.enum(BOOK_LINE_ORNAMENTS).describe('A motif in the middle, as on a divider.').optional(), + /** Moves the points; the frame is fitted around them. */ + points: bookLinePointsInSchema.optional(), +}; diff --git a/shared/src/book/book-line.schema.ts b/shared/src/book/book-line.schema.ts new file mode 100644 index 000000000..94560e32e --- /dev/null +++ b/shared/src/book/book-line.schema.ts @@ -0,0 +1,69 @@ +import { z } from 'zod'; + +/** + * A drawn line: a rule, a route across a map, an arrow from a caption to the + * picture it is about, a hand-drawn squiggle under a title. + * + * ── Why its own kind ───────────────────────────────────────────────────── + * + * The rule the Studio always had is a hairline rectangle, and it stays: it + * is a box, snaps like a box and every book made so far is full of them. A + * box cannot point anywhere, though. A line has two ends that go where they + * are put, a head on either end, a bend, and a body that can be dashed, + * dotted, wavy or wobbled as though drawn by hand. + * + * ── Why the points are fractions of the frame ─────────────────────────── + * + * The element keeps a frame like every other one, so it moves, turns, locks, + * aligns and parks with no special case anywhere in the editor or the engine. + * Its points are stored as fractions of that frame, so anything that moves or + * scales the frame (a drag, the `frame` operation, a spread pasted into a + * book of another size) carries the line along without knowing it is one. + * Dragging an end moves the point and refits the frame around the points + * (book-line.ts), which is the only place the two have to agree. + */ + +const unit = z.number().finite().min(0).max(1); +const hex = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'expected #rrggbb'); + +export const MAX_LINE_POINTS = 32; + +/** What sits on an end: nothing, a filled head, an open chevron, a dot, a cross bar. */ +export const BOOK_LINE_CAPS = ['none', 'arrow', 'open', 'dot', 'bar'] as const; +export type BookLineCap = (typeof BOOK_LINE_CAPS)[number]; + +/** A motif in the middle of a rule, the way a title is set between two hairlines. */ +export const BOOK_LINE_ORNAMENTS = ['none', 'diamond', 'dot', 'star', 'sun', 'heart'] as const; +export type BookLineOrnament = (typeof BOOK_LINE_ORNAMENTS)[number]; + +export const BOOK_LINE_FIELDS = { + kind: z.literal('line'), + /** The points in order, as fractions of the frame. Two is a line, more a path. */ + points: z + .array(z.object({ x: unit, y: unit })) + .min(2) + .max(MAX_LINE_POINTS) + .default([ + { x: 0, y: 0.5 }, + { x: 1, y: 0.5 }, + ]), + /** Straight between the points, or one smooth curve through them. */ + curve: z.enum(['straight', 'smooth']).default('straight'), + stroke: hex.default('#141414'), + /** Millimetres. Named as a shape's outline is, so every place that rescales one rescales the other. */ + strokeWidth: z + .number() + .finite() + .min(0.05) + .max(20) + .transform((v) => Math.round(v * 100) / 100) + .default(0.5), + dash: z.enum(['solid', 'dashed', 'dotted']).default('solid'), + /** The body of the line drawn as a wave or a zigzag along its course. */ + squiggle: z.enum(['none', 'wave', 'zigzag']).default('none'), + startCap: z.enum(BOOK_LINE_CAPS).default('none'), + endCap: z.enum(BOOK_LINE_CAPS).default('none'), + /** How much it wavers, as if drawn by hand: 0 is ruled. Seeded by the element, so it wavers the same way every time. */ + wobble: z.number().min(0).max(1).default(0), + ornament: z.enum(BOOK_LINE_ORNAMENTS).default('none'), +}; diff --git a/shared/src/book/book-line.ts b/shared/src/book/book-line.ts new file mode 100644 index 000000000..82f5ddef2 --- /dev/null +++ b/shared/src/book/book-line.ts @@ -0,0 +1,111 @@ +import type { BookLineCap } from './book-line.schema'; +import type { BookFrame, BookLineElement } from './book.schema'; + +/** + * The geometry of a line element, in one place for the editor's handles and + * the book engine alike: where its points are on the spread, and the frame and + * fractions that put them back there. + * + * A line's points live as fractions of its frame (book-line.schema.ts). The + * frame is the box around the points, padded so the stroke and its heads stay + * inside it, and never thinner than `LINE_MIN_EXTENT`: a level rule has no + * height of its own, and a frame of zero height is one the contract refuses + * and nobody can click. + */ + +type Pt = readonly [number, number]; + +/** The thinnest a line's frame gets across, so a level rule still has something to hold. */ +export const LINE_MIN_EXTENT = 2; + +const r2 = (v: number) => Math.round(v * 100) / 100; +const r4 = (v: number) => Math.round(v * 10000) / 10000; + +/** How long an end's head is, in millimetres, for a line of this width: readable on a hairline, not a club on a thick one. */ +export function bookLineCapSize(width: number): number { + return r2(1.6 + width * 3.2); +} + +/** How far the frame reaches past the points: half the stroke, and most of a head where there is one. */ +export function bookLinePad(width: number, caps: readonly BookLineCap[] = []): number { + const capped = caps.some((c) => c !== 'none'); + return r2(width / 2 + (capped ? bookLineCapSize(width) * 0.6 : 0) + 0.5); +} + +function rotate([x, y]: Pt, [cx, cy]: Pt, deg: number): [number, number] { + if (!deg) return [x, y]; + const a = (deg * Math.PI) / 180; + const cos = Math.cos(a); + const sin = Math.sin(a); + const dx = x - cx; + const dy = y - cy; + return [cx + dx * cos - dy * sin, cy + dx * sin + dy * cos]; +} + +/** The points of a line on the spread, in millimetres, its turn applied: where they are drawn. */ +export function bookLinePoints(el: Pick): [number, number][] { + const { x, y, w, h } = el.frame; + const centre: Pt = [x + w / 2, y + h / 2]; + return el.points.map((p) => rotate([x + p.x * w, y + p.y * h], centre, el.rotation)); +} + +/** + * The frame and the fractions for points on the spread, the line turned by + * `rotation`. + * + * Turned lines are the reason this is not a bounding box: the frame is + * measured in the line's own turned axes, and its centre is turned back onto + * the spread, so every point lands exactly where it was given whatever the + * angle. A line at 0 degrees is the plain box around its points. + */ +export function bookLineFrame( + points: readonly Pt[], + opts: { rotation?: number; pad?: number } = {}, +): { frame: BookFrame; points: { x: number; y: number }[] } { + const rotation = opts.rotation ?? 0; + const pad = opts.pad ?? 1; + const local = points.map((p) => rotate(p, [0, 0], -rotation)); + const xs = local.map((p) => p[0]); + const ys = local.map((p) => p[1]); + let x0 = Math.min(...xs) - pad; + let x1 = Math.max(...xs) + pad; + let y0 = Math.min(...ys) - pad; + let y1 = Math.max(...ys) + pad; + if (x1 - x0 < LINE_MIN_EXTENT) { + const mid = (x0 + x1) / 2; + x0 = mid - LINE_MIN_EXTENT / 2; + x1 = mid + LINE_MIN_EXTENT / 2; + } + if (y1 - y0 < LINE_MIN_EXTENT) { + const mid = (y0 + y1) / 2; + y0 = mid - LINE_MIN_EXTENT / 2; + y1 = mid + LINE_MIN_EXTENT / 2; + } + const w = r2(x1 - x0); + const h = r2(y1 - y0); + const [cx, cy] = rotate([(x0 + x1) / 2, (y0 + y1) / 2], [0, 0], rotation); + const frame = { x: r2(cx - w / 2), y: r2(cy - h / 2), w, h }; + const fractions = local.map(([px, py]) => ({ + x: r4(Math.min(1, Math.max(0, (px - x0) / w))), + y: r4(Math.min(1, Math.max(0, (py - y0) / h))), + })); + return { frame, points: fractions }; +} + +/** A line refitted around new points on the spread, keeping its turn and padding for its stroke and heads. */ +export function bookLineWithPoints(el: T, points: readonly Pt[]): T { + const pad = bookLinePad(el.strokeWidth, [el.startCap, el.endCap]); + const fit = bookLineFrame(points, { rotation: el.rotation, pad }); + return { ...el, frame: fit.frame, points: fit.points }; +} + +/** How long the line runs from its first point to its last, in millimetres, along its points. */ +export function bookLineLength(points: readonly Pt[]): number { + let total = 0; + for (let i = 1; i < points.length; i++) { + const a = points[i - 1]; + const b = points[i]; + if (a && b) total += Math.hypot(b[0] - a[0], b[1] - a[1]); + } + return total; +} diff --git a/shared/src/book/book-lines.spec.ts b/shared/src/book/book-lines.spec.ts new file mode 100644 index 000000000..edca6858e --- /dev/null +++ b/shared/src/book/book-lines.spec.ts @@ -0,0 +1,278 @@ +import { strokeFindings } from './book-findings-lines'; +import { + LINE_MIN_EXTENT, + bookLineCapSize, + bookLineFrame, + bookLineLength, + bookLinePad, + bookLinePoints, + bookLineWithPoints, +} from './book-line'; +import { normalizeBookDocument } from './book-normalize'; +import { applyBookOps } from './book-ops'; +import { bookLineStyled, bookNewLine } from './book-ops-line'; +import { BOOK_FINDING_CODES, type BookPageOpInput } from './book-ops.schema'; +import { blankBook } from './book-pages'; +import { + BOOK_EDGE_SHAPES, + BOOK_SHAPES_MORE, + BOOK_SHAPE_ASPECT, + bookShapeSize, + bookShapeTakesEdge, +} from './book-shapes-more'; +import { + BOOK_SHAPES, + bookElementSchema, + bookLineElementSchema, + bookPhotoElementSchema, + bookShapeElementSchema, + bookSpreadSchema, + type BookDocument, + type BookElement, + type BookLineElement, + type BookSpread, +} from './book.schema'; +import { bookEdgeWords, bookLineWords } from './describe-lines'; +import { describeSpread } from './describe-spread'; + +import { describe, expect, it } from 'vitest'; + +/** + * The second shelf of shapes, cut edges and the drawn line: what the document + * keeps, the geometry the editor and the engine share, the `add` and `style` + * operations the assistant reaches them by, what a page description says of + * them and the two print checks. + */ + +let seq = 0; +const newId = (p: string) => `${p}-n${++seq}`; +const frame = { x: 230, y: 40, w: 80, h: 60 }; + +const line = (over: Partial = {}) => + bookLineElementSchema.parse({ id: 'l1', kind: 'line', frame: { x: 220, y: 100, w: 100, h: 4 }, ...over }); + +function book(elements: BookElement[]): BookDocument { + const doc = blankBook({ title: 'Iceland', newId: (p) => `${p}-x` }); + const ids = ['sp-cover', 'sp-first', 'sp-in', 'sp-last', 'sp-back']; + const spreads = doc.spreads.map((s, i) => ({ ...s, id: ids[i] as string })); + spreads[2] = { ...(spreads[2] as BookSpread), elements }; + return { ...doc, spreads }; +} + +const inner = (doc: BookDocument) => doc.spreads[2] as BookSpread; +const run = (doc: BookDocument, ops: BookPageOpInput[]) => applyBookOps(doc, { spreadId: 'sp-in', ops }, { newId }); + +function ok(result: ReturnType): BookDocument { + if (!result.ok) throw new Error(`refused: ${result.error.code} ${result.error.message}`); + expect(bookSpreadSchema.safeParse(inner(result.doc)).success).toBe(true); + return result.doc; +} +const last = (doc: BookDocument) => inner(doc).elements[inner(doc).elements.length - 1] as BookElement; +const near = (a: readonly number[], b: readonly number[]) => a.every((v, i) => Math.abs(v - (b[i] ?? NaN)) < 0.05); + +describe('the document', () => { + it('BKLINE-001: appends the second shelf behind the first, every id once, and keeps the aspect of each', () => { + const first = BOOK_SHAPES.indexOf(BOOK_SHAPES_MORE[0]); + expect(first).toBeGreaterThan(BOOK_SHAPES.indexOf('banner-flag')); + expect(new Set(BOOK_SHAPES).size).toBe(BOOK_SHAPES.length); + expect(Object.keys(BOOK_SHAPE_ASPECT).sort()).toEqual([...BOOK_SHAPES_MORE].sort()); + for (const s of BOOK_EDGE_SHAPES) expect(BOOK_SHAPES).toContain(s); + }); + + it('BKLINE-002: reads an edge on a shape and a photo as optional, so a book from before parses back as it was', () => { + const old = bookShapeElementSchema.parse({ id: 's', kind: 'shape', frame }); + expect('edge' in old).toBe(false); + const cut = bookShapeElementSchema.parse({ id: 's', kind: 'shape', frame, edge: 'perforated', edgeSize: 2.346 }); + expect(cut).toMatchObject({ edge: 'perforated', edgeSize: 2.35 }); + expect(bookShapeElementSchema.safeParse({ id: 's', kind: 'shape', frame, edgeSize: 0.1 }).success).toBe(false); + const photo = bookPhotoElementSchema.parse({ id: 'p', kind: 'photo', frame, mask: 'arch-tall', edge: 'deckle' }); + expect(photo).toMatchObject({ mask: 'arch-tall', edge: 'deckle' }); + }); + + it('BKLINE-003: fills a line from defaults, bounds its points, and keeps it through the salvage pass', () => { + const el = line(); + expect(el).toMatchObject({ curve: 'straight', strokeWidth: 0.5, dash: 'solid', startCap: 'none', wobble: 0 }); + expect(el.points).toEqual([ + { x: 0, y: 0.5 }, + { x: 1, y: 0.5 }, + ]); + expect(bookLineElementSchema.safeParse({ ...el, points: [{ x: 0, y: 0 }] }).success).toBe(false); + expect(bookLineElementSchema.safeParse({ ...el, points: [{ x: 1.2, y: 0 }, el.points[1]] }).success).toBe(false); + expect(bookElementSchema.parse(el)).toEqual(el); + const doc = normalizeBookDocument(book([el, { id: 'bad', kind: 'line', frame, points: 'no' } as never])); + expect(inner(doc).elements.map((e) => e.id)).toEqual(['l1']); + }); +}); + +describe('the geometry of a line', () => { + it('BKLINE-004: fits a frame round its points, padded for its stroke, never thinner than a click', () => { + const level = bookLineFrame( + [ + [10, 50], + [90, 50], + ], + { pad: 1 }, + ); + expect(level.frame).toEqual({ x: 9, y: 49, w: 82, h: LINE_MIN_EXTENT }); + expect(level.points).toEqual([ + { x: 0.0122, y: 0.5 }, + { x: 0.9878, y: 0.5 }, + ]); + expect(bookLinePad(0.5)).toBe(0.75); + expect(bookLinePad(0.5, ['none', 'arrow'])).toBeGreaterThan(bookLinePad(0.5)); + expect(bookLineCapSize(1)).toBe(4.8); + expect( + bookLineLength([ + [0, 0], + [3, 4], + [3, 10], + ]), + ).toBe(11); + }); + + it('BKLINE-005: puts every point back where it was given, whatever the turn', () => { + const points: [number, number][] = [ + [230, 40], + [300, 90], + [260, 150], + ]; + for (const rotation of [0, 17, -90, 135]) { + const placed = bookLineWithPoints(line({ rotation }), points); + expect(placed.rotation).toBe(rotation); + bookLinePoints(placed).forEach((p, i) => expect(near(p, points[i] as number[])).toBe(true)); + } + }); +}); + +describe('the line operations', () => { + it('BKLINE-006: adds a line half a page wide on the right-hand page, or through the points named', () => { + const plain = last(ok(run(book([]), [{ op: 'add', element: { kind: 'line' } }]))); + expect(plain).toMatchObject({ kind: 'line', id: expect.stringMatching(/^l-/) }); + const ends = bookLinePoints(plain as BookLineElement); + expect(near(ends[0] as number[], [262.5, 105])).toBe(true); + expect(near(ends[1] as number[], [367.5, 105])).toBe(true); + + const element = { + kind: 'line' as const, + points: [ + { x: 230, y: 60 }, + { x: 330, y: 140 }, + ], + endCap: 'arrow' as const, + dash: 'dotted' as const, + strokeWidth: 0.8, + }; + const arrow = last(ok(run(book([]), [{ op: 'add', element }]))) as BookLineElement; + expect(arrow).toMatchObject({ endCap: 'arrow', dash: 'dotted', strokeWidth: 0.8 }); + expect(near(bookLinePoints(arrow)[1] as number[], [330, 140])).toBe(true); + const inFrame = last(ok(run(book([]), [{ op: 'add', element: { kind: 'line' }, frame }]))) as BookLineElement; + expect(near(bookLinePoints(inFrame)[0] as number[], [230, 70])).toBe(true); + }); + + it('BKLINE-007: restyles a line, refits it when its ends move or grow a head, and refuses another kind', () => { + const el = line(); + const moved = ok( + run(book([el]), [ + { + op: 'style', + elementId: 'l1', + set: { + kind: 'line', + points: [ + { x: 230, y: 30 }, + { x: 260, y: 80 }, + { x: 300, y: 30 }, + ], + curve: 'smooth', + }, + }, + ]), + ); + const out = inner(moved).elements[0] as BookLineElement; + expect(out.points).toHaveLength(3); + expect(out.curve).toBe('smooth'); + expect(near(bookLinePoints(out)[1] as number[], [260, 80])).toBe(true); + + const headed = bookLineStyled(el, { endCap: 'arrow', strokeWidth: 2 }); + expect(headed.frame.h).toBeGreaterThan(el.frame.h); + bookLinePoints(headed).forEach((p, i) => expect(near(p, bookLinePoints(el)[i] as number[])).toBe(true)); + expect(bookLineStyled(el, { stroke: '#ff0000' }).frame).toEqual(el.frame); + + const wrong = run(book([el]), [{ op: 'style', elementId: 'l1', set: { kind: 'shape', fill: '#000000' } }]); + expect(wrong.ok ? null : wrong.error.code).toBe('wrong_kind'); + const moveFrame = ok(run(book([el]), [{ op: 'frame', elementId: 'l1', x: 10 }])); + expect((inner(moveFrame).elements[0] as BookLineElement).points).toEqual(el.points); + }); + + it('BKLINE-008: makes a new line from the Studio shelf on a single page as on a spread', () => { + const page = book([]).page; + const single = bookNewLine({ dash: 'dashed' }, { id: 'l-s', frame: null, inner: false, page }); + expect(near(bookLinePoints(single)[0] as number[], [52.5, 105])).toBe(true); + expect(single.dash).toBe('dashed'); + }); +}); + +describe('edges and the second shelf through the operations', () => { + it('BKLINE-009: cuts an edge on a shape and a photo by style, and sizes a new shape at its own proportions', () => { + const shape = bookShapeElementSchema.parse({ id: 's1', kind: 'shape', frame }); + const photo = bookPhotoElementSchema.parse({ id: 'p1', kind: 'photo', frame }); + const doc = ok( + run(book([shape, photo]), [ + { op: 'style', elementId: 's1', set: { kind: 'shape', edge: 'zigzag', edgeSize: 2.333 } }, + { op: 'style', elementId: 'p1', set: { kind: 'photo', edge: 'perforated', mask: 'stamp-rect' } }, + ]), + ); + expect(inner(doc).elements[0]).toMatchObject({ edge: 'zigzag', edgeSize: 2.33 }); + expect(inner(doc).elements[1]).toMatchObject({ edge: 'perforated', mask: 'stamp-rect' }); + + const ribbon = last(ok(run(book([]), [{ op: 'add', element: { kind: 'shape', shape: 'ribbon-ends' } }]))); + expect(ribbon.frame.w / ribbon.frame.h).toBeCloseTo(BOOK_SHAPE_ASPECT['ribbon-ends'], 1); + expect(bookShapeSize('heart', 50)).toBeNull(); + const scroll = bookShapeSize('flourish-scroll', 50); + expect(scroll?.w).toBe(100); + expect(bookShapeTakesEdge(null)).toBe(true); + expect(bookShapeTakesEdge('heart')).toBe(false); + }); +}); + +describe('what a page says of them', () => { + it('BKLINE-010: describes a line by its points, length and look, and an edge by its kind and depth', () => { + const el = line({ endCap: 'arrow', wobble: 0.5, ornament: 'star', squiggle: 'wave', dash: 'dashed' }); + const words = bookLineWords(el); + expect(words).toMatch(/^line through \(220, 102\) \(320, 102\), 100 mm long/); + expect(words).toContain('#141414 0.5 mm dashed'); + expect(words).toContain('ends none / arrow'); + expect(words).toContain('hand-drawn wobble 0.5'); + expect(words).toContain('star in the middle'); + expect(words).toContain('drawn as a wave'); + expect(bookEdgeWords({ edge: 'torn', edgeSize: 4 })).toBe('torn edge 4 mm'); + expect(bookEdgeWords({ edge: 'straight' })).toBeNull(); + expect(bookEdgeWords({})).toBeNull(); + + const shape = bookShapeElementSchema.parse({ id: 's1', kind: 'shape', frame, edge: 'scallop' }); + const doc = book([el, shape]); + const text = describeSpread(doc, 'sp-in')?.text ?? ''; + expect(text).toContain('line l1 at'); + expect(text).toContain('scallop edge 3 mm'); + }); + + it('BKLINE-011: warns of a line or outline too thin to print, and of an edge set where none is cut', () => { + expect(BOOK_FINDING_CODES).toEqual(expect.arrayContaining(['hairline', 'edge_unused'])); + const thin = line({ strokeWidth: 0.05 }); + const outline = bookShapeElementSchema.parse({ + id: 's1', + kind: 'shape', + frame, + stroke: '#000000', + strokeWidth: 0.06, + }); + const heart = bookShapeElementSchema.parse({ id: 's2', kind: 'shape', frame, shape: 'heart', edge: 'zigzag' }); + const fine = bookShapeElementSchema.parse({ id: 's3', kind: 'shape', frame, edge: 'zigzag', strokeWidth: 0.05 }); + const photo = bookPhotoElementSchema.parse({ id: 'p1', kind: 'photo', frame, mask: 'star-5', edge: 'torn' }); + const spread = inner(book([thin, outline, heart, fine, photo])); + const found = strokeFindings(spread).map((f) => `${f.code}:${f.elementIds.join()}`); + expect(found).toEqual(['hairline:l1', 'hairline:s1', 'edge_unused:s2', 'edge_unused:p1']); + const all = describeSpread(book([thin]), 'sp-in')?.findings.map((f) => f.code) ?? []; + expect(all).toContain('hairline'); + }); +}); diff --git a/shared/src/book/book-op-enums.ts b/shared/src/book/book-op-enums.ts index 984e1a400..a502550f2 100644 --- a/shared/src/book/book-op-enums.ts +++ b/shared/src/book/book-op-enums.ts @@ -15,6 +15,7 @@ import { bookShapeElementSchema, bookStatsElementSchema, bookTextElementSchema, + type BookShapeId, } from './book.schema'; import { z } from 'zod'; @@ -58,15 +59,26 @@ export const bookWeightSchema = bookTextElementSchema.shape.weight export const bookShapeIdSchema = z .enum(BOOK_SHAPES) .describe( - 'A filled outline: rect, ellipse, line (a rule), polygons, arch and circle parts, stars, burst, seal, sparkle, arrows, chevrons, speech bubbles, nature and travel motifs, blobs and banners.', + 'A filled outline: rect, ellipse, line (a rule), polygons, arches and circle parts, stars, burst, seal, sparkle, arrows, chevrons, speech bubbles, nature and travel motifs, blobs, banners and ribbons, paper cut-outs (leaf, coral, organic), stamps and badges, photo frames, ornaments (flourish, laurel, fleuron).', ); export const bookPhotoFitSchema = photo.fit .unwrap() .describe('cover fills the frame, cropped at the focal point; contain shows all of it.'); -export const bookPhotoMaskSchema = photo.mask - .unwrap() - .describe('A shape id to cut the photo to; null is the plain rectangle.'); +const SHAPE_IDS: ReadonlySet = new Set(BOOK_SHAPES); + +/** + * A shape id to cut a photo to. Checked against the set rather than listed: + * the list is in the schema already under `shape`, and repeating it on every + * photo field doubled the page edit's schema. + */ +export const bookPhotoMaskSchema = z + .string() + .max(40) + .refine((s) => SHAPE_IDS.has(s), { message: 'not a shape the Studio draws' }) + .transform((s) => s as BookShapeId) + .nullable() + .describe('A shape id, as for a shape, to cut the photo to; null is the plain rectangle.'); export const bookFrameStyleSchema = photo.frameStyle .unwrap() .describe('polaroid (deep bottom border), white border, shadow, film or tape around it; none is a plain print.'); diff --git a/shared/src/book/book-ops-elements.ts b/shared/src/book/book-ops-elements.ts index 0cc83557f..7f43bb620 100644 --- a/shared/src/book/book-ops-elements.ts +++ b/shared/src/book/book-ops-elements.ts @@ -16,10 +16,12 @@ import { unlockedElement, type BookOpsContext, } from './book-ops-core'; +import { bookLineStyled, bookNewLine } from './book-ops-line'; import { newStickerElement } from './book-ops-sticker'; import { withShapePaint } from './book-paint'; import type { BookPaint } from './book-paint.schema'; import { BOOK_PHOTO_LOOK_KEYS } from './book-photo-look.schema'; +import { bookShapeSize } from './book-shapes-more'; import { bookBadgeValue, bookCountryMark, @@ -147,12 +149,15 @@ function lookKeys(shape: object): string[] { return Object.keys(shape).filter((k) => k !== 'kind'); } +const MM_KEYS: ReadonlySet = new Set(['radius', 'strokeWidth', 'edgeSize']); + /** * The element with the given look fields set; millimetre fields rounded as the * document stores them, and the weight moved onto one its family has, as the * Studio's weight control does. */ function withProps(el: BookElement, props: Record): BookElement { + if (el.kind === 'line') return bookLineStyled(el, props); const keys = PROP_KEYS.get(el.kind) ?? []; const next: Record = { ...el }; for (const key of keys) { @@ -163,7 +168,7 @@ function withProps(el: BookElement, props: Record): BookElement delete next[key]; continue; } - next[key] = (key === 'radius' || key === 'strokeWidth') && typeof value === 'number' ? round2(value) : value; + next[key] = MM_KEYS.has(key) && typeof value === 'number' ? round2(value) : value; } const made = withRealWeight(next as unknown as BookElement); return made.kind === 'shape' ? withPaintProps(made, props) : made; @@ -215,6 +220,8 @@ function created(spread: BookSpread, spec: BookNewElement, frame: BookFrame | nu return newText(spec, at, env); case 'shape': return newShape(spec, frame, spread, env); + case 'line': + return bookNewLine(spec, { id: env.newId('l'), frame, inner: isInner(spread), page: env.page }); case 'icon': { const side = Math.min(env.page.pageWidth, env.page.pageHeight) * 0.18; return { @@ -313,7 +320,7 @@ function newShape( const rule = s === 'line'; const size = rule ? { w: env.page.pageWidth * 0.5, h: 0.5 } - : { w: wide ? side * 1.7 : side, h: tall ? side * 1.35 : wide ? side * 0.55 : side }; + : (bookShapeSize(s, side) ?? { w: wide ? side * 1.7 : side, h: tall ? side * 1.35 : wide ? side * 0.55 : side }); return { ...BASE, id: env.newId('s'), diff --git a/shared/src/book/book-ops-line.ts b/shared/src/book/book-ops-line.ts new file mode 100644 index 000000000..bb99d769b --- /dev/null +++ b/shared/src/book/book-ops-line.ts @@ -0,0 +1,103 @@ +import { bookLineFrame, bookLinePad, bookLineWithPoints, bookLinePoints } from './book-line'; +import type { BookFrame, BookLineElement, BookPageSetup } from './book.schema'; + +/** + * What `add` and `style` do to a line, for the element operations + * (book-ops-elements.ts): the Studio's own new line, and a restyle that + * refits the frame when the points move or the ends grow a head. + */ + +type Pt = readonly [number, number]; + +const LOOK_KEYS = [ + 'opacity', + 'stroke', + 'strokeWidth', + 'dash', + 'curve', + 'squiggle', + 'startCap', + 'endCap', + 'wobble', + 'ornament', +] as const; + +/** What a line op may carry: its look, and points on the spread in millimetres. */ +interface BookLineSpec { + points?: readonly { x: number; y: number }[]; + [key: string]: unknown; +} + +const tuples = (points: readonly { x: number; y: number }[]): Pt[] => points.map((p) => [p.x, p.y]); + +/** The look fields of a spec set on a line, untouched where the spec leaves them out. */ +function withLook(el: BookLineElement, spec: BookLineSpec): BookLineElement { + const next: Record = { ...el }; + for (const key of LOOK_KEYS) { + const value = spec[key]; + if (value === undefined) continue; + next[key] = key === 'strokeWidth' && typeof value === 'number' ? Math.round(value * 100) / 100 : value; + } + return next as unknown as BookLineElement; +} + +/** + * A line restyled. New points, a wider stroke or a new head each refit the + * frame, so the ends stay where they are drawn and the heads stay inside it. + */ +export function bookLineStyled(el: BookLineElement, spec: BookLineSpec): BookLineElement { + const styled = withLook(el, spec); + const reaches = spec.strokeWidth !== undefined || spec.startCap !== undefined || spec.endCap !== undefined; + const points = spec.points ? tuples(spec.points) : reaches ? bookLinePoints(el) : null; + return points ? bookLineWithPoints(styled, points) : styled; +} + +/** + * A new line, as the Studio's Lines shelf places it: a rule across the middle + * of the given frame, or half a page wide across the right-hand page of a + * spread (the left-hand one is the fold's other side), unless points are named. + */ +export function bookNewLine( + spec: BookLineSpec, + opts: { id: string; frame: BookFrame | null; inner: boolean; page: BookPageSetup }, +): BookLineElement { + const { page } = opts; + const origin = opts.inner ? page.pageWidth : 0; + const box = opts.frame ?? { + x: origin + page.pageWidth * 0.25, + y: page.pageHeight / 2, + w: page.pageWidth * 0.5, + h: 0, + }; + const mid = box.y + box.h / 2; + const points: readonly Pt[] = spec.points + ? tuples(spec.points) + : [ + [box.x, mid], + [box.x + box.w, mid], + ]; + const base: BookLineElement = { + id: opts.id, + kind: 'line', + rotation: 0, + opacity: 1, + locked: false, + frame: { x: 0, y: 0, w: 1, h: 1 }, + points: [ + { x: 0, y: 0.5 }, + { x: 1, y: 0.5 }, + ], + curve: 'straight', + stroke: '#141414', + strokeWidth: 0.5, + dash: 'solid', + squiggle: 'none', + startCap: 'none', + endCap: 'none', + wobble: 0, + ornament: 'none', + }; + const look = withLook(base, spec); + const fit = bookLineFrame(points, { pad: bookLinePad(look.strokeWidth, [look.startCap, look.endCap]) }); + return { ...look, frame: fit.frame, points: fit.points }; +} diff --git a/shared/src/book/book-ops.schema.ts b/shared/src/book/book-ops.schema.ts index 9d1eb0be8..81cbb1d95 100644 --- a/shared/src/book/book-ops.schema.ts +++ b/shared/src/book/book-ops.schema.ts @@ -413,6 +413,8 @@ export const BOOK_FINDING_CODES = [ 'palette_drift', 'repeat_layout', 'raster_effect', + 'hairline', + 'edge_unused', ] as const; export type BookFindingCode = (typeof BOOK_FINDING_CODES)[number]; diff --git a/shared/src/book/book-paint.ts b/shared/src/book/book-paint.ts index ebe1672c9..76e85f76f 100644 --- a/shared/src/book/book-paint.ts +++ b/shared/src/book/book-paint.ts @@ -188,6 +188,8 @@ function elementColours(el: BookElement): string[] { return [lower(el.color)]; case 'sticker': return el.colors.map(lower); + case 'line': + return [lower(el.stroke)]; default: return [lower(el.color), lower(el.accent)]; } diff --git a/shared/src/book/book-schema-rev.ts b/shared/src/book/book-schema-rev.ts index 3f4621681..5ccc71788 100644 --- a/shared/src/book/book-schema-rev.ts +++ b/shared/src/book/book-schema-rev.ts @@ -36,8 +36,10 @@ import type { BookDocument } from './book.schema'; * - 4: the sticker kind (book-sticker.ts, stickers/). * - 5: groups, names and hidden layers (`group`, `name`, `hidden`, a spread's `groups`). * - 6: the photo look (`border`, `shadow`, `zoom`, `flipX`, `adjust`, `duotone`, `cutout`, book-photo-look.schema.ts). + * - 7: the second shelf of shapes, the cut edge of a shape and a photo (`edge`, `edgeSize`, + * book-shapes-more.ts) and the line kind (book-line.schema.ts). */ -export const BOOK_SCHEMA_REV = 6; +export const BOOK_SCHEMA_REV = 7; /** The `code` a refused save carries in its body when the stored book is newer than the writer. */ export const BOOK_SCHEMA_NEWER_CODE = 'book_schema_newer'; diff --git a/shared/src/book/book-shapes-more.ts b/shared/src/book/book-shapes-more.ts new file mode 100644 index 000000000..bd7eabefd --- /dev/null +++ b/shared/src/book/book-shapes-more.ts @@ -0,0 +1,227 @@ +import { z } from 'zod'; + +/** + * The second shelf of shapes: the craft of a printed travel page rather than + * its geometry. Arches to cut a photo into, paper cut-outs in the manner of + * Matisse, postage stamps and postmarks, ribbons, tickets, speech bubbles, + * arrows that curve, and the flourishes a title sits between. + * + * Kept apart from the first list in book.schema.ts so the two can grow + * without touching each other, and appended behind it: a document only ever + * stores the id, so the order matters to the panel alone. + * + * Every entry is a single filled outline. The client draws them from its + * shape table (client/src/components/Studio/shapes-more.ts and friends), and a + * test there holds that every id here has a path. + */ +export const BOOK_SHAPES_MORE = [ + // Arches, the frames a Mediterranean page is built from. + 'arch-tall', + 'arch-pointed', + 'arch-double', + 'arch-half', + 'arch-rainbow', + + // Paper cut-outs: leaves, coral and weed, wonky stars and suns. + 'leaf-palm', + 'leaf-oak', + 'leaf-long', + 'leaf-monstera', + 'coral-1', + 'coral-2', + 'seaweed', + 'star-organic', + 'sun-cutout', + 'organic-1', + 'organic-2', + 'organic-3', + + // Stamps, badges and the things a travel scrapbook is stuck with. + 'stamp-rect', + 'stamp-circle', + 'postmark', + 'rosette', + 'scallop-circle', + 'badge-shield', + 'label-tab', + 'ticket-stub', + 'sunburst-12', + 'sunburst-24', + + // Ribbons and bands. + 'ribbon-ends', + 'ribbon-arc', + 'wavy-band', + 'torn-strip', + + // Speech. + 'bubble-shout', + 'bubble-side', + 'bubble-capsule', + + // Arrows that are drawn rather than ruled. + 'arrow-curve', + 'arrow-circle', + 'arrow-notched', + 'arrow-thin', + + // Hearts and stars. + 'heart-round', + 'heart-pair', + 'star-rounded', + 'star-twinkle', + 'flower-8', + + // Photo frames. + 'frame-notched', + 'frame-chamfer', + 'frame-cushion', + + // Ornaments. + 'flourish-scroll', + 'laurel', + 'fleuron', + 'corner-flourish', +] as const; + +/** + * How the outline of a shape, or of a photo's cut, is finished. + * + * `straight` is the drawn outline as it is. The rest replace it with a cut + * edge that bites into the shape by `edgeSize`: a zigzag of pinking shears, + * scallops, a postage stamp's perforation, a wave, a torn sheet and the soft + * irregular deckle of handmade paper. + */ +export const BOOK_EDGES = ['straight', 'zigzag', 'scallop', 'perforated', 'wavy', 'torn', 'deckle'] as const; +export type BookEdge = (typeof BOOK_EDGES)[number]; + +/** + * The shapes an edge is cut along: the broad, convex ones, where a bite into + * the outline stays inside it. A cut along a heart's notch or a star's + * valleys folds over itself, so those keep their drawn outline. + */ +export const BOOK_EDGE_SHAPES = [ + 'rect', + 'squircle', + 'ellipse', + 'capsule', + 'arch', + 'arch-tall', + 'half-circle', + 'triangle', + 'diamond', + 'pentagon', + 'hexagon', + 'hexagon-flat', + 'octagon', + 'shield', + 'frame-chamfer', + 'frame-cushion', + 'organic-1', + 'organic-2', + 'organic-3', +] as const; + +const EDGE_SET: ReadonlySet = new Set(BOOK_EDGE_SHAPES); + +/** Whether an edge is cut along this shape; null is a photo's plain rectangle. */ +export function bookShapeTakesEdge(shape: string | null): boolean { + return shape === null || EDGE_SET.has(shape); +} + +/** The finest and the deepest edge, in millimetres. */ +export const BOOK_EDGE_SIZE_MIN = 0.5; +export const BOOK_EDGE_SIZE_MAX = 30; + +/** + * The two fields a shape and a photo carry for their edge. + * + * Optional rather than defaulted: a document written before edges existed + * reads exactly as it did, and parses back to exactly what was stored. + */ +export const BOOK_EDGE_FIELDS = { + edge: z.enum(BOOK_EDGES).optional(), + /** How deep the cut bites and, with it, how long one tooth is. Millimetres. */ + edgeSize: z + .number() + .finite() + .min(BOOK_EDGE_SIZE_MIN) + .max(BOOK_EDGE_SIZE_MAX) + .transform((v) => Math.round(v * 100) / 100) + .optional(), +}; + +/** + * How wide each shape of this shelf is drawn against its height. + * + * The table stretches every outline to its frame, so a shape has no + * proportions of its own once placed; these are the ones it was drawn at, + * which is the frame the panel and the `add` operation give it and the box + * its tile is drawn in. A client test holds them to the drawings. + */ +export const BOOK_SHAPE_ASPECT: Readonly> = { + 'arch-tall': 0.5, + 'arch-pointed': 0.53, + 'arch-double': 1.25, + 'arch-half': 0.63, + 'arch-rainbow': 2, + 'stamp-rect': 0.81, + 'stamp-circle': 1, + postmark: 1, + rosette: 0.76, + 'scallop-circle': 1, + 'badge-shield': 1.04, + 'label-tab': 2, + 'ticket-stub': 2.22, + 'sunburst-12': 1, + 'sunburst-24': 1, + 'ribbon-ends': 3.7, + 'ribbon-arc': 3.03, + 'wavy-band': 3.13, + 'torn-strip': 4.78, + 'bubble-shout': 1.02, + 'bubble-side': 1.43, + 'bubble-capsule': 2.08, + 'arrow-curve': 3.77, + 'arrow-circle': 1.12, + 'arrow-notched': 2, + 'arrow-thin': 5.56, + 'heart-round': 1.06, + 'heart-pair': 1.25, + 'star-rounded': 1.05, + 'star-twinkle': 1, + 'flower-8': 1, + 'frame-notched': 1, + 'frame-chamfer': 1, + 'frame-cushion': 1, + 'leaf-palm': 1.06, + 'leaf-oak': 0.46, + 'leaf-long': 0.22, + 'leaf-monstera': 1.11, + 'coral-1': 1.58, + 'coral-2': 1.45, + seaweed: 0.38, + 'star-organic': 1.05, + 'sun-cutout': 0.98, + 'organic-1': 1.14, + 'organic-2': 0.86, + 'organic-3': 1.11, + 'flourish-scroll': 7.32, + laurel: 1.19, + fleuron: 1.29, + 'corner-flourish': 0.99, +}; + +/** + * The size a new shape of this shelf arrives at: `side` across for a square + * one, and the same area at its own proportions otherwise. Null for a shape of + * the first shelf, which keeps the sizes it always had. + */ +export function bookShapeSize(shape: string, side: number): { w: number; h: number } | null { + const aspect = (BOOK_SHAPE_ASPECT as Readonly>)[shape]; + if (aspect === undefined) return null; + // Very long ones (a scroll, a thin arrow) are held to a band rather than a needle. + const root = Math.sqrt(Math.min(aspect, 4)); + const w = side * root; + return { w, h: w / aspect }; +} diff --git a/shared/src/book/book.schema.ts b/shared/src/book/book.schema.ts index 3ecf2d32f..c1faee679 100644 --- a/shared/src/book/book.schema.ts +++ b/shared/src/book/book.schema.ts @@ -1,6 +1,8 @@ import { BOOK_FONTS_IDS, BOOK_WEIGHTS, type BookFontFamily } from './book-fonts.data'; +import { BOOK_LINE_FIELDS } from './book-line.schema'; import { bookPaintFieldSchema } from './book-paint.schema'; import { BOOK_PHOTO_LOOK_FIELDS } from './book-photo-look.schema'; +import { BOOK_EDGE_FIELDS, BOOK_SHAPES_MORE } from './book-shapes-more'; import { MAX_STICKER_SLOTS } from './stickers/sticker-types'; import { z } from 'zod'; @@ -158,6 +160,8 @@ export const BOOK_SHAPES = [ 'banner-pennant', 'banner-bookmark', 'banner-flag', + + ...BOOK_SHAPES_MORE, ] as const; export type BookShapeId = (typeof BOOK_SHAPES)[number]; @@ -256,6 +260,8 @@ export const bookPhotoElementSchema = z.object({ frameStyle: z.enum(['none', 'polaroid', 'white', 'shadow', 'film', 'tape']).default('none'), /** Border, hard shadow, zoom, mirror, adjustments, duotone and cutout (book-photo-look.schema.ts). */ ...BOOK_PHOTO_LOOK_FIELDS, + /** A cut edge along the mask, or along the rectangle (book-shapes-more.ts). */ + ...BOOK_EDGE_FIELDS, }); export const bookTextElementSchema = z.object({ @@ -343,8 +349,13 @@ export const bookShapeElementSchema = z.object({ */ strokeStyle: z.enum(['solid', 'dashed', 'dotted']).default('solid'), radius: mm.default(0), + /** A cut edge along the outline (book-shapes-more.ts). */ + ...BOOK_EDGE_FIELDS, }); +/** A drawn line with two or more points (book-line.schema.ts). */ +export const bookLineElementSchema = z.object({ ...elementBase, ...BOOK_LINE_FIELDS }); + /** * ── The travel elements ──────────────────────────────────────────────────── * @@ -857,10 +868,12 @@ export const bookElementSchema = z.discriminatedUnion('kind', [ bookListElementSchema, bookIconElementSchema, bookStickerElementSchema, + bookLineElementSchema, ]); export type BookElement = z.infer; export type BookIconElement = z.infer; export type BookStickerElement = z.infer; +export type BookLineElement = z.infer; export type BookPhotoElement = z.infer; export type BookTextElement = z.infer; export type BookShapeElement = z.infer; diff --git a/shared/src/book/describe-lines.ts b/shared/src/book/describe-lines.ts new file mode 100644 index 000000000..c803db40e --- /dev/null +++ b/shared/src/book/describe-lines.ts @@ -0,0 +1,32 @@ +import { bookLineLength, bookLinePoints } from './book-line'; +import type { BookLineElement, BookPhotoElement, BookShapeElement } from './book.schema'; + +/** + * The words describe-spread.ts says for a line and for a cut edge: what a + * model needs to move a line's end or to know a frame is a postage stamp. + */ + +const n = (v: number) => `${Math.round(v * 10) / 10}`; + +/** A shape's or a photo's cut edge, or nothing when it keeps its drawn outline. */ +export function bookEdgeWords(el: Pick): string | null { + if (!el.edge || el.edge === 'straight') return null; + return `${el.edge} edge ${n(el.edgeSize ?? 3)} mm`; +} + +/** A line: where its points are on the spread, how long it runs, and how it is drawn. */ +export function bookLineWords(el: BookLineElement): string { + const pts = bookLinePoints(el); + const shown = pts.slice(0, 6).map(([x, y]) => `(${n(x)}, ${n(y)})`); + const more = pts.length > 6 ? ` and ${pts.length - 6} more` : ''; + const parts = [ + `line through ${shown.join(' ')}${more}, ${n(bookLineLength(pts))} mm long`, + `${el.stroke} ${n(el.strokeWidth)} mm ${el.dash}`, + ]; + if (el.curve === 'smooth' && pts.length > 2) parts.push('smooth curve'); + if (el.squiggle !== 'none') parts.push(`drawn as a ${el.squiggle}`); + if (el.startCap !== 'none' || el.endCap !== 'none') parts.push(`ends ${el.startCap} / ${el.endCap}`); + if (el.wobble > 0) parts.push(`hand-drawn wobble ${n(el.wobble)}`); + if (el.ornament !== 'none') parts.push(`${el.ornament} in the middle`); + return parts.join(', '); +} diff --git a/shared/src/book/describe-spread.ts b/shared/src/book/describe-spread.ts index a8103d405..2b89ac80b 100644 --- a/shared/src/book/describe-spread.ts +++ b/shared/src/book/describe-spread.ts @@ -23,6 +23,7 @@ import { type BookPhotoElement, type BookSpread, } from './book.schema'; +import { bookEdgeWords, bookLineWords } from './describe-lines'; /** * A page in words, for a model that cannot see it and as the text beside a @@ -78,6 +79,8 @@ function photoWords(el: BookPhotoElement, info: BookPhotoInfo | undefined): stri if (el.filter !== 'none') parts.push(`filter ${el.filter}`); if (el.mask) parts.push(`mask ${el.mask}`); if (el.frameStyle !== 'none') parts.push(`frame style ${el.frameStyle}`); + const edge = bookEdgeWords(el); + if (edge) parts.push(edge); if (el.radius > 0) parts.push(`radius ${n(el.radius)}`); parts.push(...photoLookWords(el)); return parts.join(', '); @@ -103,6 +106,8 @@ function elementWords(el: BookElement, photos: readonly BookPhotoInfo[] | undefi return `icon ${el.name}, ${el.color}, line ${n(el.lineWidth)}`; case 'sticker': return bookStickerWords(el); + case 'line': + return bookLineWords(el); default: return travelWords(el); } @@ -113,6 +118,8 @@ function shapeWords(el: Extract): string { if (!el.paint && el.gradient !== 'none') parts.push(`fading ${el.gradient}`); if (el.stroke) parts.push(`stroke ${el.stroke} ${n(el.strokeWidth)} mm ${el.strokeStyle}`); if (el.radius > 0) parts.push(`radius ${n(el.radius)}`); + const edge = bookEdgeWords(el); + if (edge) parts.push(edge); return parts.join(', '); } diff --git a/shared/src/i18n/ar/index.ts b/shared/src/i18n/ar/index.ts index 60250d2d3..c0a37ffe0 100644 --- a/shared/src/i18n/ar/index.ts +++ b/shared/src/i18n/ar/index.ts @@ -64,6 +64,7 @@ import studioLayers from './studioLayers'; import studioPaint from './studioPaint'; import studioPhoto from './studioPhoto'; import studioStickers from './studioStickers'; +import studioShapes from './studioShapes'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -124,6 +125,7 @@ const locale = { ...studioPaint, ...studioStickers, ...studioPhoto, + ...studioShapes, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/ar/studioShapes.ts b/shared/src/i18n/ar/studioShapes.ts new file mode 100644 index 000000000..9e4a2f41b --- /dev/null +++ b/shared/src/i18n/ar/studioShapes.ts @@ -0,0 +1,70 @@ +import type { TranslationStrings } from '../types'; + +/** Studio's second shelf of shapes, cut edges and drawn lines, and their two print checks. */ +const studioShapes: TranslationStrings = { + 'journey.studio.shapeGroup.arches': 'أقواس', + 'journey.studio.shapeGroup.cutouts': 'قصاصات', + 'journey.studio.shapeGroup.stamps': 'طوابع وشارات', + 'journey.studio.shapeGroup.frames': 'إطارات', + 'journey.studio.shapeGroup.ornaments': 'زخارف', + 'journey.studio.drawnLines': 'خطوط مرسومة', + 'journey.studio.dividers': 'فواصل', + 'journey.studio.linePreset.straight': 'خط مستقيم', + 'journey.studio.linePreset.arrow': 'سهم', + 'journey.studio.linePreset.double': 'سهم مزدوج', + 'journey.studio.linePreset.dashed': 'خط متقطع', + 'journey.studio.linePreset.dotted': 'خط منقط', + 'journey.studio.linePreset.curved': 'سهم منحن', + 'journey.studio.linePreset.wave': 'خط متموج', + 'journey.studio.linePreset.zigzag': 'خط متعرج', + 'journey.studio.linePreset.hand': 'سهم مرسوم باليد', + 'journey.studio.linePreset.route': 'مسار منقط', + 'journey.studio.linePreset.divider-diamond': 'خط مع معين', + 'journey.studio.linePreset.divider-sun': 'خط مع شمس', + 'journey.studio.linePreset.divider-star': 'خط مع نجمة', + 'journey.studio.linePreset.divider-heart': 'خط مع قلب', + 'journey.studio.linePreset.divider-dot': 'خط مع نقطة', + 'journey.studio.kind.line': 'خط', + 'journey.studio.edgeStyle': 'حافة', + 'journey.studio.edge.straight': 'مستقيمة', + 'journey.studio.edge.zigzag': 'متعرجة', + 'journey.studio.edge.scallop': 'مُصدَّفة', + 'journey.studio.edge.perforated': 'مثقبة', + 'journey.studio.edge.wavy': 'متموجة', + 'journey.studio.edge.torn': 'ممزقة', + 'journey.studio.edge.deckle': 'حافة ورق يدوي', + 'journey.studio.edgeSize': 'العمق', + 'journey.studio.edgeUnavailable': + 'يحتفظ هذا الشكل بحدوده المرسومة. تُقص الحواف على الأشكال العريضة مثل المستطيلات والدوائر والأقواس.', + 'journey.studio.wobble': 'باليد', + 'journey.studio.lineCourseTitle': 'المسار', + 'journey.studio.lineCourse.straight': 'مستقيم', + 'journey.studio.lineCourse.smooth': 'منحن', + 'journey.studio.lineBodyTitle': 'الجسم', + 'journey.studio.lineBody.none': 'بسيط', + 'journey.studio.lineBody.wave': 'موجة', + 'journey.studio.lineBody.zigzag': 'تعرج', + 'journey.studio.linePointsHint': + 'اسحب نقطة على الصفحة لتحريكها. اسحب مقبضًا صغيرًا بين نقطتين لإضافة نقطة، وانقر نقرًا مزدوجًا على نقطة لإزالتها.', + 'journey.studio.lineEnds': 'الطرفان', + 'journey.studio.lineStart': 'البداية', + 'journey.studio.lineEnd': 'النهاية', + 'journey.studio.cap.none': 'بلا طرف', + 'journey.studio.cap.arrow': 'سهم', + 'journey.studio.cap.open': 'سهم مفتوح', + 'journey.studio.cap.dot': 'نقطة', + 'journey.studio.cap.bar': 'شَرطة', + 'journey.studio.swapEnds': 'تبديل الطرفين', + 'journey.studio.ornamentTitle': 'زخرفة', + 'journey.studio.ornament.none': 'لا شيء', + 'journey.studio.ornament.diamond': 'معين', + 'journey.studio.ornament.dot': 'نقطة', + 'journey.studio.ornament.star': 'نجمة', + 'journey.studio.ornament.sun': 'شمس', + 'journey.studio.ornament.heart': 'قلب', + 'journey.studio.addPoint': 'اسحب لإضافة نقطة', + 'journey.studio.removePoint': 'انقر مرتين للإزالة', + 'assistant.card.book.finding.hairline': 'خط أرق من أن يُطبع', + 'assistant.card.book.finding.edge_unused': 'حافة لا يقبلها هذا الشكل', +}; +export default studioShapes; diff --git a/shared/src/i18n/az/index.ts b/shared/src/i18n/az/index.ts index 57b825437..fd911c33d 100644 --- a/shared/src/i18n/az/index.ts +++ b/shared/src/i18n/az/index.ts @@ -64,6 +64,7 @@ import studioLayers from './studioLayers'; import studioPaint from './studioPaint'; import studioPhoto from './studioPhoto'; import studioStickers from './studioStickers'; +import studioShapes from './studioShapes'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -124,6 +125,7 @@ const locale = { ...studioPaint, ...studioStickers, ...studioPhoto, + ...studioShapes, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/az/studioShapes.ts b/shared/src/i18n/az/studioShapes.ts new file mode 100644 index 000000000..a9780eb9c --- /dev/null +++ b/shared/src/i18n/az/studioShapes.ts @@ -0,0 +1,70 @@ +import type { TranslationStrings } from '../types'; + +/** Studio's second shelf of shapes, cut edges and drawn lines, and their two print checks. */ +const studioShapes: TranslationStrings = { + 'journey.studio.shapeGroup.arches': 'Tağlar', + 'journey.studio.shapeGroup.cutouts': 'Kəsiklər', + 'journey.studio.shapeGroup.stamps': 'Markalar və nişanlar', + 'journey.studio.shapeGroup.frames': 'Çərçivələr', + 'journey.studio.shapeGroup.ornaments': 'Ornamentlər', + 'journey.studio.drawnLines': 'Çəkilmiş xətlər', + 'journey.studio.dividers': 'Ayırıcılar', + 'journey.studio.linePreset.straight': 'Düz xətt', + 'journey.studio.linePreset.arrow': 'Ox', + 'journey.studio.linePreset.double': 'İkitərəfli ox', + 'journey.studio.linePreset.dashed': 'Qırıq xətt', + 'journey.studio.linePreset.dotted': 'Nöqtəli xətt', + 'journey.studio.linePreset.curved': 'Əyri ox', + 'journey.studio.linePreset.wave': 'Dalğalı xətt', + 'journey.studio.linePreset.zigzag': 'Ziqzaq xətt', + 'journey.studio.linePreset.hand': 'Əllə çəkilmiş ox', + 'journey.studio.linePreset.route': 'Nöqtəli marşrut', + 'journey.studio.linePreset.divider-diamond': 'Rombla xətt', + 'journey.studio.linePreset.divider-sun': 'Günəşlə xətt', + 'journey.studio.linePreset.divider-star': 'Ulduzla xətt', + 'journey.studio.linePreset.divider-heart': 'Ürəklə xətt', + 'journey.studio.linePreset.divider-dot': 'Nöqtə ilə xətt', + 'journey.studio.kind.line': 'Xətt', + 'journey.studio.edgeStyle': 'Kənar', + 'journey.studio.edge.straight': 'Düz', + 'journey.studio.edge.zigzag': 'Ziqzaq', + 'journey.studio.edge.scallop': 'Dilimli', + 'journey.studio.edge.perforated': 'Perforasiyalı', + 'journey.studio.edge.wavy': 'Dalğalı', + 'journey.studio.edge.torn': 'Cırılmış', + 'journey.studio.edge.deckle': 'Əl işi kağız kənarı', + 'journey.studio.edgeSize': 'Dərinlik', + 'journey.studio.edgeUnavailable': + 'Bu forma çəkilmiş konturunu saxlayır. Kənarlar düzbucaqlı, dairə və tağ kimi geniş formalar boyunca kəsilir.', + 'journey.studio.wobble': 'Əllə', + 'journey.studio.lineCourseTitle': 'Gediş', + 'journey.studio.lineCourse.straight': 'Düz', + 'journey.studio.lineCourse.smooth': 'Əyri', + 'journey.studio.lineBodyTitle': 'Gövdə', + 'journey.studio.lineBody.none': 'Sadə', + 'journey.studio.lineBody.wave': 'Dalğa', + 'journey.studio.lineBody.zigzag': 'Ziqzaq', + 'journey.studio.linePointsHint': + 'Nöqtəni yerini dəyişmək üçün səhifədə sürüşdür. İki nöqtə arasındakı kiçik tutacağı çəkərək yeni nöqtə əlavə et, nöqtəyə iki dəfə klikləyərək onu sil.', + 'journey.studio.lineEnds': 'Uclar', + 'journey.studio.lineStart': 'Başlanğıc', + 'journey.studio.lineEnd': 'Son', + 'journey.studio.cap.none': 'Ucsuz', + 'journey.studio.cap.arrow': 'Ox', + 'journey.studio.cap.open': 'Açıq ox', + 'journey.studio.cap.dot': 'Nöqtə', + 'journey.studio.cap.bar': 'Zolaq', + 'journey.studio.swapEnds': 'Ucları dəyiş', + 'journey.studio.ornamentTitle': 'Ornament', // same-as-en + 'journey.studio.ornament.none': 'Yoxdur', + 'journey.studio.ornament.diamond': 'Romb', + 'journey.studio.ornament.dot': 'Nöqtə', + 'journey.studio.ornament.star': 'Ulduz', + 'journey.studio.ornament.sun': 'Günəş', + 'journey.studio.ornament.heart': 'Ürək', + 'journey.studio.addPoint': 'Nöqtə əlavə etmək üçün çək', + 'journey.studio.removePoint': 'Silmək üçün iki dəfə kliklə', + 'assistant.card.book.finding.hairline': 'Çap üçün çox nazik xətt', + 'assistant.card.book.finding.edge_unused': 'Bu formanın qəbul etmədiyi kənar', +}; +export default studioShapes; diff --git a/shared/src/i18n/br/index.ts b/shared/src/i18n/br/index.ts index 1777741e6..1233ddef7 100644 --- a/shared/src/i18n/br/index.ts +++ b/shared/src/i18n/br/index.ts @@ -64,6 +64,7 @@ import studioLayers from './studioLayers'; import studioPaint from './studioPaint'; import studioPhoto from './studioPhoto'; import studioStickers from './studioStickers'; +import studioShapes from './studioShapes'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -124,6 +125,7 @@ const locale = { ...studioPaint, ...studioStickers, ...studioPhoto, + ...studioShapes, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/br/studioShapes.ts b/shared/src/i18n/br/studioShapes.ts new file mode 100644 index 000000000..ad9d817c2 --- /dev/null +++ b/shared/src/i18n/br/studioShapes.ts @@ -0,0 +1,70 @@ +import type { TranslationStrings } from '../types'; + +/** Studio's second shelf of shapes, cut edges and drawn lines, and their two print checks. */ +const studioShapes: TranslationStrings = { + 'journey.studio.shapeGroup.arches': 'Arcos', + 'journey.studio.shapeGroup.cutouts': 'Recortes', + 'journey.studio.shapeGroup.stamps': 'Selos e emblemas', + 'journey.studio.shapeGroup.frames': 'Molduras', + 'journey.studio.shapeGroup.ornaments': 'Ornamentos', + 'journey.studio.drawnLines': 'Linhas desenhadas', + 'journey.studio.dividers': 'Divisores', + 'journey.studio.linePreset.straight': 'Linha reta', + 'journey.studio.linePreset.arrow': 'Seta', + 'journey.studio.linePreset.double': 'Seta dupla', + 'journey.studio.linePreset.dashed': 'Linha tracejada', + 'journey.studio.linePreset.dotted': 'Linha pontilhada', + 'journey.studio.linePreset.curved': 'Seta curva', + 'journey.studio.linePreset.wave': 'Linha ondulada', + 'journey.studio.linePreset.zigzag': 'Linha em zigue-zague', + 'journey.studio.linePreset.hand': 'Seta feita à mão', + 'journey.studio.linePreset.route': 'Rota pontilhada', + 'journey.studio.linePreset.divider-diamond': 'Fio com um losango', + 'journey.studio.linePreset.divider-sun': 'Fio com um sol', + 'journey.studio.linePreset.divider-star': 'Fio com uma estrela', + 'journey.studio.linePreset.divider-heart': 'Fio com um coração', + 'journey.studio.linePreset.divider-dot': 'Fio com um ponto', + 'journey.studio.kind.line': 'Linha', + 'journey.studio.edgeStyle': 'Borda', + 'journey.studio.edge.straight': 'Reta', + 'journey.studio.edge.zigzag': 'Zigue-zague', + 'journey.studio.edge.scallop': 'Recortada em ondas', + 'journey.studio.edge.perforated': 'Picotada', + 'journey.studio.edge.wavy': 'Ondulada', + 'journey.studio.edge.torn': 'Rasgada', + 'journey.studio.edge.deckle': 'Borda de papel artesanal', + 'journey.studio.edgeSize': 'Profundidade', + 'journey.studio.edgeUnavailable': + 'Esta forma mantém o contorno desenhado. As bordas são recortadas em formas largas, como retângulos, círculos e arcos.', + 'journey.studio.wobble': 'À mão livre', + 'journey.studio.lineCourseTitle': 'Traçado', + 'journey.studio.lineCourse.straight': 'Reto', + 'journey.studio.lineCourse.smooth': 'Curvo', + 'journey.studio.lineBodyTitle': 'Corpo', + 'journey.studio.lineBody.none': 'Simples', + 'journey.studio.lineBody.wave': 'Onda', + 'journey.studio.lineBody.zigzag': 'Zigue-zague', + 'journey.studio.linePointsHint': + 'Arraste um ponto na página para movê-lo. Puxe uma alça pequena entre dois pontos para adicionar um, dê um clique duplo em um ponto para removê-lo.', + 'journey.studio.lineEnds': 'Pontas', + 'journey.studio.lineStart': 'Início', + 'journey.studio.lineEnd': 'Fim', + 'journey.studio.cap.none': 'Sem ponta', + 'journey.studio.cap.arrow': 'Seta', + 'journey.studio.cap.open': 'Seta aberta', + 'journey.studio.cap.dot': 'Ponto', + 'journey.studio.cap.bar': 'Barra', + 'journey.studio.swapEnds': 'Trocar as pontas', + 'journey.studio.ornamentTitle': 'Ornamento', + 'journey.studio.ornament.none': 'Nenhum', + 'journey.studio.ornament.diamond': 'Losango', + 'journey.studio.ornament.dot': 'Ponto', + 'journey.studio.ornament.star': 'Estrela', + 'journey.studio.ornament.sun': 'Sol', + 'journey.studio.ornament.heart': 'Coração', + 'journey.studio.addPoint': 'Puxe para adicionar um ponto', + 'journey.studio.removePoint': 'Clique duplo para remover', + 'assistant.card.book.finding.hairline': 'Uma linha fina demais para imprimir', + 'assistant.card.book.finding.edge_unused': 'Uma borda que esta forma não aceita', +}; +export default studioShapes; diff --git a/shared/src/i18n/ca/index.ts b/shared/src/i18n/ca/index.ts index faf621f1b..697f7aeab 100644 --- a/shared/src/i18n/ca/index.ts +++ b/shared/src/i18n/ca/index.ts @@ -64,6 +64,7 @@ import studioLayers from './studioLayers'; import studioPaint from './studioPaint'; import studioPhoto from './studioPhoto'; import studioStickers from './studioStickers'; +import studioShapes from './studioShapes'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -125,6 +126,7 @@ const locale = { ...studioPaint, ...studioStickers, ...studioPhoto, + ...studioShapes, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/ca/studioShapes.ts b/shared/src/i18n/ca/studioShapes.ts new file mode 100644 index 000000000..225353f77 --- /dev/null +++ b/shared/src/i18n/ca/studioShapes.ts @@ -0,0 +1,70 @@ +import type { TranslationStrings } from '../types'; + +/** Studio's second shelf of shapes, cut edges and drawn lines, and their two print checks. */ +const studioShapes: TranslationStrings = { + 'journey.studio.shapeGroup.arches': 'Arcs', + 'journey.studio.shapeGroup.cutouts': 'Retalls', + 'journey.studio.shapeGroup.stamps': 'Segells i insígnies', + 'journey.studio.shapeGroup.frames': 'Marcs', + 'journey.studio.shapeGroup.ornaments': 'Ornaments', // same-as-en + 'journey.studio.drawnLines': 'Línies dibuixades', + 'journey.studio.dividers': 'Separadors', + 'journey.studio.linePreset.straight': 'Línia recta', + 'journey.studio.linePreset.arrow': 'Fletxa', + 'journey.studio.linePreset.double': 'Fletxa doble', + 'journey.studio.linePreset.dashed': 'Línia discontínua', + 'journey.studio.linePreset.dotted': 'Línia de punts', + 'journey.studio.linePreset.curved': 'Fletxa corba', + 'journey.studio.linePreset.wave': 'Línia ondulada', + 'journey.studio.linePreset.zigzag': 'Línia en ziga-zaga', + 'journey.studio.linePreset.hand': 'Fletxa a mà alçada', + 'journey.studio.linePreset.route': 'Ruta de punts', + 'journey.studio.linePreset.divider-diamond': 'Filet amb un rombe', + 'journey.studio.linePreset.divider-sun': 'Filet amb un sol', + 'journey.studio.linePreset.divider-star': 'Filet amb una estrella', + 'journey.studio.linePreset.divider-heart': 'Filet amb un cor', + 'journey.studio.linePreset.divider-dot': 'Filet amb un punt', + 'journey.studio.kind.line': 'Línia', + 'journey.studio.edgeStyle': 'Vora', + 'journey.studio.edge.straight': 'Recta', + 'journey.studio.edge.zigzag': 'Ziga-zaga', + 'journey.studio.edge.scallop': 'Festonejada', + 'journey.studio.edge.perforated': 'Perforada', + 'journey.studio.edge.wavy': 'Ondulada', + 'journey.studio.edge.torn': 'Esquinçada', + 'journey.studio.edge.deckle': 'Vora de paper fet a mà', + 'journey.studio.edgeSize': 'Profunditat', + 'journey.studio.edgeUnavailable': + 'Aquesta forma conserva el contorn dibuixat. Les vores es retallen en formes amples com rectangles, cercles i arcs.', + 'journey.studio.wobble': 'A mà alçada', + 'journey.studio.lineCourseTitle': 'Traçat', + 'journey.studio.lineCourse.straight': 'Recte', + 'journey.studio.lineCourse.smooth': 'Corbat', + 'journey.studio.lineBodyTitle': 'Cos', + 'journey.studio.lineBody.none': 'Llis', + 'journey.studio.lineBody.wave': 'Ona', + 'journey.studio.lineBody.zigzag': 'Ziga-zaga', + 'journey.studio.linePointsHint': + "Arrossega un punt a la pàgina per moure'l. Estira una nansa petita entre dos punts per afegir-ne un, fes doble clic en un punt per treure'l.", + 'journey.studio.lineEnds': 'Extrems', + 'journey.studio.lineStart': 'Inici', + 'journey.studio.lineEnd': 'Final', + 'journey.studio.cap.none': 'Sense extrem', + 'journey.studio.cap.arrow': 'Fletxa', + 'journey.studio.cap.open': 'Fletxa oberta', + 'journey.studio.cap.dot': 'Punt', + 'journey.studio.cap.bar': 'Barra', + 'journey.studio.swapEnds': 'Intercanvia els extrems', + 'journey.studio.ornamentTitle': 'Ornament', // same-as-en + 'journey.studio.ornament.none': 'Cap', + 'journey.studio.ornament.diamond': 'Rombe', + 'journey.studio.ornament.dot': 'Punt', + 'journey.studio.ornament.star': 'Estrella', + 'journey.studio.ornament.sun': 'Sol', + 'journey.studio.ornament.heart': 'Cor', + 'journey.studio.addPoint': 'Estira per afegir un punt', + 'journey.studio.removePoint': 'Doble clic per treure', + 'assistant.card.book.finding.hairline': 'Una línia massa fina per imprimir', + 'assistant.card.book.finding.edge_unused': 'Una vora que aquesta forma no admet', +}; +export default studioShapes; diff --git a/shared/src/i18n/cs/index.ts b/shared/src/i18n/cs/index.ts index 60250d2d3..c0a37ffe0 100644 --- a/shared/src/i18n/cs/index.ts +++ b/shared/src/i18n/cs/index.ts @@ -64,6 +64,7 @@ import studioLayers from './studioLayers'; import studioPaint from './studioPaint'; import studioPhoto from './studioPhoto'; import studioStickers from './studioStickers'; +import studioShapes from './studioShapes'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -124,6 +125,7 @@ const locale = { ...studioPaint, ...studioStickers, ...studioPhoto, + ...studioShapes, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/cs/studioShapes.ts b/shared/src/i18n/cs/studioShapes.ts new file mode 100644 index 000000000..5e151fcb7 --- /dev/null +++ b/shared/src/i18n/cs/studioShapes.ts @@ -0,0 +1,70 @@ +import type { TranslationStrings } from '../types'; + +/** Studio's second shelf of shapes, cut edges and drawn lines, and their two print checks. */ +const studioShapes: TranslationStrings = { + 'journey.studio.shapeGroup.arches': 'Oblouky', + 'journey.studio.shapeGroup.cutouts': 'Vystřihovánky', + 'journey.studio.shapeGroup.stamps': 'Známky a odznaky', + 'journey.studio.shapeGroup.frames': 'Rámečky', + 'journey.studio.shapeGroup.ornaments': 'Ornamenty', + 'journey.studio.drawnLines': 'Kreslené čáry', + 'journey.studio.dividers': 'Oddělovače', + 'journey.studio.linePreset.straight': 'Rovná čára', + 'journey.studio.linePreset.arrow': 'Šipka', + 'journey.studio.linePreset.double': 'Dvojitá šipka', + 'journey.studio.linePreset.dashed': 'Čárkovaná čára', + 'journey.studio.linePreset.dotted': 'Tečkovaná čára', + 'journey.studio.linePreset.curved': 'Zahnutá šipka', + 'journey.studio.linePreset.wave': 'Vlnovka', + 'journey.studio.linePreset.zigzag': 'Klikatá čára', + 'journey.studio.linePreset.hand': 'Ručně kreslená šipka', + 'journey.studio.linePreset.route': 'Tečkovaná trasa', + 'journey.studio.linePreset.divider-diamond': 'Linka s kosočtvercem', + 'journey.studio.linePreset.divider-sun': 'Linka se sluncem', + 'journey.studio.linePreset.divider-star': 'Linka s hvězdou', + 'journey.studio.linePreset.divider-heart': 'Linka se srdcem', + 'journey.studio.linePreset.divider-dot': 'Linka s tečkou', + 'journey.studio.kind.line': 'Čára', + 'journey.studio.edgeStyle': 'Okraj', + 'journey.studio.edge.straight': 'Rovný', + 'journey.studio.edge.zigzag': 'Cik cak', + 'journey.studio.edge.scallop': 'Vroubkovaný', + 'journey.studio.edge.perforated': 'Perforovaný', + 'journey.studio.edge.wavy': 'Vlnitý', + 'journey.studio.edge.torn': 'Natržený', + 'journey.studio.edge.deckle': 'Ruční papír', + 'journey.studio.edgeSize': 'Hloubka', + 'journey.studio.edgeUnavailable': + 'Tento tvar si ponechá nakreslený obrys. Okraje se stříhají podél širokých tvarů, jako jsou obdélníky, kruhy a oblouky.', + 'journey.studio.wobble': 'Od ruky', + 'journey.studio.lineCourseTitle': 'Průběh', + 'journey.studio.lineCourse.straight': 'Rovný', + 'journey.studio.lineCourse.smooth': 'Zakřivený', + 'journey.studio.lineBodyTitle': 'Tělo', + 'journey.studio.lineBody.none': 'Prostý', + 'journey.studio.lineBody.wave': 'Vlna', + 'journey.studio.lineBody.zigzag': 'Cik cak', + 'journey.studio.linePointsHint': + 'Přetažením bodu na stránce ho posunete. Zatáhněte za malý úchyt mezi dvěma body a přidáte bod, dvojklikem na bod ho odeberete.', + 'journey.studio.lineEnds': 'Konce', + 'journey.studio.lineStart': 'Začátek', + 'journey.studio.lineEnd': 'Konec', + 'journey.studio.cap.none': 'Bez konce', + 'journey.studio.cap.arrow': 'Šipka', + 'journey.studio.cap.open': 'Otevřená šipka', + 'journey.studio.cap.dot': 'Tečka', + 'journey.studio.cap.bar': 'Příčka', + 'journey.studio.swapEnds': 'Prohodit konce', + 'journey.studio.ornamentTitle': 'Ornament', // same-as-en + 'journey.studio.ornament.none': 'Žádný', + 'journey.studio.ornament.diamond': 'Kosočtverec', + 'journey.studio.ornament.dot': 'Tečka', + 'journey.studio.ornament.star': 'Hvězda', + 'journey.studio.ornament.sun': 'Slunce', + 'journey.studio.ornament.heart': 'Srdce', + 'journey.studio.addPoint': 'Táhněte pro přidání bodu', + 'journey.studio.removePoint': 'Dvojklikem odeberete', + 'assistant.card.book.finding.hairline': 'Čára příliš tenká pro tisk', + 'assistant.card.book.finding.edge_unused': 'Okraj, který tento tvar nepřijme', +}; +export default studioShapes; diff --git a/shared/src/i18n/de/index.ts b/shared/src/i18n/de/index.ts index 60250d2d3..c0a37ffe0 100644 --- a/shared/src/i18n/de/index.ts +++ b/shared/src/i18n/de/index.ts @@ -64,6 +64,7 @@ import studioLayers from './studioLayers'; import studioPaint from './studioPaint'; import studioPhoto from './studioPhoto'; import studioStickers from './studioStickers'; +import studioShapes from './studioShapes'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -124,6 +125,7 @@ const locale = { ...studioPaint, ...studioStickers, ...studioPhoto, + ...studioShapes, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/de/studioShapes.ts b/shared/src/i18n/de/studioShapes.ts new file mode 100644 index 000000000..da4d044f9 --- /dev/null +++ b/shared/src/i18n/de/studioShapes.ts @@ -0,0 +1,70 @@ +import type { TranslationStrings } from '../types'; + +/** Studio's second shelf of shapes, cut edges and drawn lines, and their two print checks. */ +const studioShapes: TranslationStrings = { + 'journey.studio.shapeGroup.arches': 'Bögen', + 'journey.studio.shapeGroup.cutouts': 'Scherenschnitte', + 'journey.studio.shapeGroup.stamps': 'Briefmarken und Abzeichen', + 'journey.studio.shapeGroup.frames': 'Rahmen', + 'journey.studio.shapeGroup.ornaments': 'Ornamente', + 'journey.studio.drawnLines': 'Gezeichnete Linien', + 'journey.studio.dividers': 'Trenner', + 'journey.studio.linePreset.straight': 'Gerade Linie', + 'journey.studio.linePreset.arrow': 'Pfeil', + 'journey.studio.linePreset.double': 'Doppelpfeil', + 'journey.studio.linePreset.dashed': 'Gestrichelte Linie', + 'journey.studio.linePreset.dotted': 'Gepunktete Linie', + 'journey.studio.linePreset.curved': 'Geschwungener Pfeil', + 'journey.studio.linePreset.wave': 'Wellenlinie', + 'journey.studio.linePreset.zigzag': 'Zickzacklinie', + 'journey.studio.linePreset.hand': 'Handgezeichneter Pfeil', + 'journey.studio.linePreset.route': 'Gepunktete Route', + 'journey.studio.linePreset.divider-diamond': 'Linie mit Raute', + 'journey.studio.linePreset.divider-sun': 'Linie mit Sonne', + 'journey.studio.linePreset.divider-star': 'Linie mit Stern', + 'journey.studio.linePreset.divider-heart': 'Linie mit Herz', + 'journey.studio.linePreset.divider-dot': 'Linie mit Punkt', + 'journey.studio.kind.line': 'Linie', + 'journey.studio.edgeStyle': 'Rand', + 'journey.studio.edge.straight': 'Gerade', + 'journey.studio.edge.zigzag': 'Zickzack', + 'journey.studio.edge.scallop': 'Gebogt', + 'journey.studio.edge.perforated': 'Perforiert', + 'journey.studio.edge.wavy': 'Wellig', + 'journey.studio.edge.torn': 'Gerissen', + 'journey.studio.edge.deckle': 'Büttenrand', + 'journey.studio.edgeSize': 'Tiefe', + 'journey.studio.edgeUnavailable': + 'Diese Form behält ihre gezeichnete Kontur. Ränder lassen sich an breiten Formen wie Rechtecken, Kreisen und Bögen schneiden.', + 'journey.studio.wobble': 'Von Hand', + 'journey.studio.lineCourseTitle': 'Verlauf', + 'journey.studio.lineCourse.straight': 'Gerade', + 'journey.studio.lineCourse.smooth': 'Geschwungen', + 'journey.studio.lineBodyTitle': 'Strich', + 'journey.studio.lineBody.none': 'Schlicht', + 'journey.studio.lineBody.wave': 'Welle', + 'journey.studio.lineBody.zigzag': 'Zickzack', + 'journey.studio.linePointsHint': + 'Zieh einen Punkt auf der Seite, um ihn zu verschieben. Zieh an einem kleinen Griff zwischen zwei Punkten, um einen hinzuzufügen, ein Doppelklick auf einen Punkt entfernt ihn.', + 'journey.studio.lineEnds': 'Enden', + 'journey.studio.lineStart': 'Anfang', + 'journey.studio.lineEnd': 'Ende', + 'journey.studio.cap.none': 'Kein Ende', + 'journey.studio.cap.arrow': 'Pfeil', + 'journey.studio.cap.open': 'Offener Pfeil', + 'journey.studio.cap.dot': 'Punkt', + 'journey.studio.cap.bar': 'Balken', + 'journey.studio.swapEnds': 'Enden tauschen', + 'journey.studio.ornamentTitle': 'Ornament', // same-as-en + 'journey.studio.ornament.none': 'Keins', + 'journey.studio.ornament.diamond': 'Raute', + 'journey.studio.ornament.dot': 'Punkt', + 'journey.studio.ornament.star': 'Stern', + 'journey.studio.ornament.sun': 'Sonne', + 'journey.studio.ornament.heart': 'Herz', + 'journey.studio.addPoint': 'Ziehen, um einen Punkt hinzuzufügen', + 'journey.studio.removePoint': 'Doppelklick zum Entfernen', + 'assistant.card.book.finding.hairline': 'Eine Linie, zu dünn für den Druck', + 'assistant.card.book.finding.edge_unused': 'Ein Rand, den diese Form nicht annimmt', +}; +export default studioShapes; diff --git a/shared/src/i18n/en/index.ts b/shared/src/i18n/en/index.ts index 60250d2d3..c0a37ffe0 100644 --- a/shared/src/i18n/en/index.ts +++ b/shared/src/i18n/en/index.ts @@ -64,6 +64,7 @@ import studioLayers from './studioLayers'; import studioPaint from './studioPaint'; import studioPhoto from './studioPhoto'; import studioStickers from './studioStickers'; +import studioShapes from './studioShapes'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -124,6 +125,7 @@ const locale = { ...studioPaint, ...studioStickers, ...studioPhoto, + ...studioShapes, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/en/studioShapes.ts b/shared/src/i18n/en/studioShapes.ts new file mode 100644 index 000000000..14e103132 --- /dev/null +++ b/shared/src/i18n/en/studioShapes.ts @@ -0,0 +1,70 @@ +import type { TranslationStrings } from '../types'; + +/** Studio's second shelf of shapes, cut edges and drawn lines, and their two print checks. */ +const studioShapes: TranslationStrings = { + 'journey.studio.shapeGroup.arches': 'Arches', + 'journey.studio.shapeGroup.cutouts': 'Cut-outs', + 'journey.studio.shapeGroup.stamps': 'Stamps and badges', + 'journey.studio.shapeGroup.frames': 'Frames', + 'journey.studio.shapeGroup.ornaments': 'Ornaments', + 'journey.studio.drawnLines': 'Drawn lines', + 'journey.studio.dividers': 'Dividers', + 'journey.studio.linePreset.straight': 'Straight line', + 'journey.studio.linePreset.arrow': 'Arrow', + 'journey.studio.linePreset.double': 'Double arrow', + 'journey.studio.linePreset.dashed': 'Dashed line', + 'journey.studio.linePreset.dotted': 'Dotted line', + 'journey.studio.linePreset.curved': 'Curved arrow', + 'journey.studio.linePreset.wave': 'Wavy line', + 'journey.studio.linePreset.zigzag': 'Zigzag line', + 'journey.studio.linePreset.hand': 'Hand-drawn arrow', + 'journey.studio.linePreset.route': 'Dotted route', + 'journey.studio.linePreset.divider-diamond': 'Rule with a diamond', + 'journey.studio.linePreset.divider-sun': 'Rule with a sun', + 'journey.studio.linePreset.divider-star': 'Rule with a star', + 'journey.studio.linePreset.divider-heart': 'Rule with a heart', + 'journey.studio.linePreset.divider-dot': 'Rule with a dot', + 'journey.studio.kind.line': 'Line', + 'journey.studio.edgeStyle': 'Edge', + 'journey.studio.edge.straight': 'Straight', + 'journey.studio.edge.zigzag': 'Zigzag', + 'journey.studio.edge.scallop': 'Scalloped', + 'journey.studio.edge.perforated': 'Perforated', + 'journey.studio.edge.wavy': 'Wavy', + 'journey.studio.edge.torn': 'Torn', + 'journey.studio.edge.deckle': 'Deckled', + 'journey.studio.edgeSize': 'Depth', + 'journey.studio.edgeUnavailable': + 'This shape keeps its drawn outline. Edges are cut along broad shapes such as rectangles, circles and arches.', + 'journey.studio.wobble': 'Hand-drawn', + 'journey.studio.lineCourseTitle': 'Course', + 'journey.studio.lineCourse.straight': 'Straight', + 'journey.studio.lineCourse.smooth': 'Curved', + 'journey.studio.lineBodyTitle': 'Body', + 'journey.studio.lineBody.none': 'Plain', + 'journey.studio.lineBody.wave': 'Wave', + 'journey.studio.lineBody.zigzag': 'Zigzag', + 'journey.studio.linePointsHint': + 'Drag a point on the page to move it. Pull a small handle between two points to add one, double-click a point to remove it.', + 'journey.studio.lineEnds': 'Ends', + 'journey.studio.lineStart': 'Start', + 'journey.studio.lineEnd': 'End', + 'journey.studio.cap.none': 'No end', + 'journey.studio.cap.arrow': 'Arrow', + 'journey.studio.cap.open': 'Open arrow', + 'journey.studio.cap.dot': 'Dot', + 'journey.studio.cap.bar': 'Bar', + 'journey.studio.swapEnds': 'Swap ends', + 'journey.studio.ornamentTitle': 'Ornament', + 'journey.studio.ornament.none': 'None', + 'journey.studio.ornament.diamond': 'Diamond', + 'journey.studio.ornament.dot': 'Dot', + 'journey.studio.ornament.star': 'Star', + 'journey.studio.ornament.sun': 'Sun', + 'journey.studio.ornament.heart': 'Heart', + 'journey.studio.addPoint': 'Pull to add a point', + 'journey.studio.removePoint': 'Double-click to remove', + 'assistant.card.book.finding.hairline': 'A line too thin to print', + 'assistant.card.book.finding.edge_unused': 'An edge this shape does not take', +}; +export default studioShapes; diff --git a/shared/src/i18n/es/index.ts b/shared/src/i18n/es/index.ts index 3de4e27c8..4390c493a 100644 --- a/shared/src/i18n/es/index.ts +++ b/shared/src/i18n/es/index.ts @@ -64,6 +64,7 @@ import studioLayers from './studioLayers'; import studioPaint from './studioPaint'; import studioPhoto from './studioPhoto'; import studioStickers from './studioStickers'; +import studioShapes from './studioShapes'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -124,6 +125,7 @@ const locale = { ...studioPaint, ...studioStickers, ...studioPhoto, + ...studioShapes, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/es/studioShapes.ts b/shared/src/i18n/es/studioShapes.ts new file mode 100644 index 000000000..dec059e53 --- /dev/null +++ b/shared/src/i18n/es/studioShapes.ts @@ -0,0 +1,70 @@ +import type { TranslationStrings } from '../types'; + +/** Studio's second shelf of shapes, cut edges and drawn lines, and their two print checks. */ +const studioShapes: TranslationStrings = { + 'journey.studio.shapeGroup.arches': 'Arcos', + 'journey.studio.shapeGroup.cutouts': 'Recortes', + 'journey.studio.shapeGroup.stamps': 'Sellos e insignias', + 'journey.studio.shapeGroup.frames': 'Marcos', + 'journey.studio.shapeGroup.ornaments': 'Ornamentos', + 'journey.studio.drawnLines': 'Líneas dibujadas', + 'journey.studio.dividers': 'Separadores', + 'journey.studio.linePreset.straight': 'Línea recta', + 'journey.studio.linePreset.arrow': 'Flecha', + 'journey.studio.linePreset.double': 'Flecha doble', + 'journey.studio.linePreset.dashed': 'Línea discontinua', + 'journey.studio.linePreset.dotted': 'Línea de puntos', + 'journey.studio.linePreset.curved': 'Flecha curva', + 'journey.studio.linePreset.wave': 'Línea ondulada', + 'journey.studio.linePreset.zigzag': 'Línea en zigzag', + 'journey.studio.linePreset.hand': 'Flecha a mano alzada', + 'journey.studio.linePreset.route': 'Ruta de puntos', + 'journey.studio.linePreset.divider-diamond': 'Filete con un rombo', + 'journey.studio.linePreset.divider-sun': 'Filete con un sol', + 'journey.studio.linePreset.divider-star': 'Filete con una estrella', + 'journey.studio.linePreset.divider-heart': 'Filete con un corazón', + 'journey.studio.linePreset.divider-dot': 'Filete con un punto', + 'journey.studio.kind.line': 'Línea', + 'journey.studio.edgeStyle': 'Borde', + 'journey.studio.edge.straight': 'Recto', + 'journey.studio.edge.zigzag': 'Zigzag', // same-as-en + 'journey.studio.edge.scallop': 'Festoneado', + 'journey.studio.edge.perforated': 'Perforado', + 'journey.studio.edge.wavy': 'Ondulado', + 'journey.studio.edge.torn': 'Rasgado', + 'journey.studio.edge.deckle': 'Barbas de papel', + 'journey.studio.edgeSize': 'Profundidad', + 'journey.studio.edgeUnavailable': + 'Esta forma conserva su contorno dibujado. Los bordes se recortan en formas amplias como rectángulos, círculos y arcos.', + 'journey.studio.wobble': 'A mano alzada', + 'journey.studio.lineCourseTitle': 'Trazado', + 'journey.studio.lineCourse.straight': 'Recto', + 'journey.studio.lineCourse.smooth': 'Curvo', + 'journey.studio.lineBodyTitle': 'Cuerpo', + 'journey.studio.lineBody.none': 'Liso', + 'journey.studio.lineBody.wave': 'Onda', + 'journey.studio.lineBody.zigzag': 'Zigzag', // same-as-en + 'journey.studio.linePointsHint': + 'Arrastra un punto en la página para moverlo. Tira de un tirador pequeño entre dos puntos para añadir uno, haz doble clic en un punto para quitarlo.', + 'journey.studio.lineEnds': 'Extremos', + 'journey.studio.lineStart': 'Inicio', + 'journey.studio.lineEnd': 'Final', + 'journey.studio.cap.none': 'Sin extremo', + 'journey.studio.cap.arrow': 'Flecha', + 'journey.studio.cap.open': 'Flecha abierta', + 'journey.studio.cap.dot': 'Punto', + 'journey.studio.cap.bar': 'Barra', + 'journey.studio.swapEnds': 'Intercambiar extremos', + 'journey.studio.ornamentTitle': 'Ornamento', + 'journey.studio.ornament.none': 'Ninguno', + 'journey.studio.ornament.diamond': 'Rombo', + 'journey.studio.ornament.dot': 'Punto', + 'journey.studio.ornament.star': 'Estrella', + 'journey.studio.ornament.sun': 'Sol', + 'journey.studio.ornament.heart': 'Corazón', + 'journey.studio.addPoint': 'Tira para añadir un punto', + 'journey.studio.removePoint': 'Doble clic para quitar', + 'assistant.card.book.finding.hairline': 'Una línea demasiado fina para imprimir', + 'assistant.card.book.finding.edge_unused': 'Un borde que esta forma no admite', +}; +export default studioShapes; diff --git a/shared/src/i18n/et/index.ts b/shared/src/i18n/et/index.ts index 57b825437..fd911c33d 100644 --- a/shared/src/i18n/et/index.ts +++ b/shared/src/i18n/et/index.ts @@ -64,6 +64,7 @@ import studioLayers from './studioLayers'; import studioPaint from './studioPaint'; import studioPhoto from './studioPhoto'; import studioStickers from './studioStickers'; +import studioShapes from './studioShapes'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -124,6 +125,7 @@ const locale = { ...studioPaint, ...studioStickers, ...studioPhoto, + ...studioShapes, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/et/studioShapes.ts b/shared/src/i18n/et/studioShapes.ts new file mode 100644 index 000000000..4f1448aef --- /dev/null +++ b/shared/src/i18n/et/studioShapes.ts @@ -0,0 +1,70 @@ +import type { TranslationStrings } from '../types'; + +/** Studio's second shelf of shapes, cut edges and drawn lines, and their two print checks. */ +const studioShapes: TranslationStrings = { + 'journey.studio.shapeGroup.arches': 'Kaared', + 'journey.studio.shapeGroup.cutouts': 'Lõiked', + 'journey.studio.shapeGroup.stamps': 'Margid ja märgid', + 'journey.studio.shapeGroup.frames': 'Raamid', + 'journey.studio.shapeGroup.ornaments': 'Ornamendid', + 'journey.studio.drawnLines': 'Joonistatud jooned', + 'journey.studio.dividers': 'Eraldajad', + 'journey.studio.linePreset.straight': 'Sirge joon', + 'journey.studio.linePreset.arrow': 'Nool', + 'journey.studio.linePreset.double': 'Kahepoolne nool', + 'journey.studio.linePreset.dashed': 'Katkendjoon', + 'journey.studio.linePreset.dotted': 'Punktiirjoon', + 'journey.studio.linePreset.curved': 'Kõver nool', + 'journey.studio.linePreset.wave': 'Lainejoon', + 'journey.studio.linePreset.zigzag': 'Siksakjoon', + 'journey.studio.linePreset.hand': 'Käsitsi joonistatud nool', + 'journey.studio.linePreset.route': 'Punktiirmarsruut', + 'journey.studio.linePreset.divider-diamond': 'Joon rombiga', + 'journey.studio.linePreset.divider-sun': 'Joon päikesega', + 'journey.studio.linePreset.divider-star': 'Joon tähega', + 'journey.studio.linePreset.divider-heart': 'Joon südamega', + 'journey.studio.linePreset.divider-dot': 'Joon punktiga', + 'journey.studio.kind.line': 'Joon', + 'journey.studio.edgeStyle': 'Serv', + 'journey.studio.edge.straight': 'Sirge', + 'journey.studio.edge.zigzag': 'Siksak', + 'journey.studio.edge.scallop': 'Kaarjas', + 'journey.studio.edge.perforated': 'Perforeeritud', + 'journey.studio.edge.wavy': 'Laineline', + 'journey.studio.edge.torn': 'Rebitud', + 'journey.studio.edge.deckle': 'Käsitöö paberi serv', + 'journey.studio.edgeSize': 'Sügavus', + 'journey.studio.edgeUnavailable': + 'See kujund jääb oma joonistatud piirjoonega. Servi lõigatakse laiade kujundite järgi, nagu ristkülikud, ringid ja kaared.', + 'journey.studio.wobble': 'Käsitsi', + 'journey.studio.lineCourseTitle': 'Kulg', + 'journey.studio.lineCourse.straight': 'Sirge', + 'journey.studio.lineCourse.smooth': 'Kõver', + 'journey.studio.lineBodyTitle': 'Keha', + 'journey.studio.lineBody.none': 'Lihtne', + 'journey.studio.lineBody.wave': 'Laine', + 'journey.studio.lineBody.zigzag': 'Siksak', + 'journey.studio.linePointsHint': + 'Lohista lehel punkti, et seda liigutada. Tõmba kahe punkti vahel väikest pidet, et lisada uus punkt, topeltklõps punktil eemaldab selle.', + 'journey.studio.lineEnds': 'Otsad', + 'journey.studio.lineStart': 'Algus', + 'journey.studio.lineEnd': 'Lõpp', + 'journey.studio.cap.none': 'Otsata', + 'journey.studio.cap.arrow': 'Nool', + 'journey.studio.cap.open': 'Avatud nool', + 'journey.studio.cap.dot': 'Punkt', + 'journey.studio.cap.bar': 'Põikjoon', + 'journey.studio.swapEnds': 'Vaheta otsad', + 'journey.studio.ornamentTitle': 'Ornament', // same-as-en + 'journey.studio.ornament.none': 'Puudub', + 'journey.studio.ornament.diamond': 'Romb', + 'journey.studio.ornament.dot': 'Punkt', + 'journey.studio.ornament.star': 'Täht', + 'journey.studio.ornament.sun': 'Päike', + 'journey.studio.ornament.heart': 'Süda', + 'journey.studio.addPoint': 'Tõmba, et lisada punkt', + 'journey.studio.removePoint': 'Topeltklõps eemaldab', + 'assistant.card.book.finding.hairline': 'Trükkimiseks liiga peenike joon', + 'assistant.card.book.finding.edge_unused': 'Serv, mida see kujund ei võta', +}; +export default studioShapes; diff --git a/shared/src/i18n/fr/index.ts b/shared/src/i18n/fr/index.ts index 60250d2d3..c0a37ffe0 100644 --- a/shared/src/i18n/fr/index.ts +++ b/shared/src/i18n/fr/index.ts @@ -64,6 +64,7 @@ import studioLayers from './studioLayers'; import studioPaint from './studioPaint'; import studioPhoto from './studioPhoto'; import studioStickers from './studioStickers'; +import studioShapes from './studioShapes'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -124,6 +125,7 @@ const locale = { ...studioPaint, ...studioStickers, ...studioPhoto, + ...studioShapes, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/fr/studioShapes.ts b/shared/src/i18n/fr/studioShapes.ts new file mode 100644 index 000000000..db0600f03 --- /dev/null +++ b/shared/src/i18n/fr/studioShapes.ts @@ -0,0 +1,70 @@ +import type { TranslationStrings } from '../types'; + +/** Studio's second shelf of shapes, cut edges and drawn lines, and their two print checks. */ +const studioShapes: TranslationStrings = { + 'journey.studio.shapeGroup.arches': 'Arches', // same-as-en + 'journey.studio.shapeGroup.cutouts': 'Découpages', + 'journey.studio.shapeGroup.stamps': 'Timbres et badges', + 'journey.studio.shapeGroup.frames': 'Cadres', + 'journey.studio.shapeGroup.ornaments': 'Ornements', + 'journey.studio.drawnLines': 'Lignes dessinées', + 'journey.studio.dividers': 'Séparateurs', + 'journey.studio.linePreset.straight': 'Ligne droite', + 'journey.studio.linePreset.arrow': 'Flèche', + 'journey.studio.linePreset.double': 'Double flèche', + 'journey.studio.linePreset.dashed': 'Ligne en tirets', + 'journey.studio.linePreset.dotted': 'Ligne pointillée', + 'journey.studio.linePreset.curved': 'Flèche courbe', + 'journey.studio.linePreset.wave': 'Ligne ondulée', + 'journey.studio.linePreset.zigzag': 'Ligne en zigzag', + 'journey.studio.linePreset.hand': 'Flèche tracée à la main', + 'journey.studio.linePreset.route': 'Itinéraire pointillé', + 'journey.studio.linePreset.divider-diamond': 'Filet avec un losange', + 'journey.studio.linePreset.divider-sun': 'Filet avec un soleil', + 'journey.studio.linePreset.divider-star': 'Filet avec une étoile', + 'journey.studio.linePreset.divider-heart': 'Filet avec un cœur', + 'journey.studio.linePreset.divider-dot': 'Filet avec un point', + 'journey.studio.kind.line': 'Ligne', + 'journey.studio.edgeStyle': 'Bord', + 'journey.studio.edge.straight': 'Droit', + 'journey.studio.edge.zigzag': 'Zigzag', // same-as-en + 'journey.studio.edge.scallop': 'Festonné', + 'journey.studio.edge.perforated': 'Perforé', + 'journey.studio.edge.wavy': 'Ondulé', + 'journey.studio.edge.torn': 'Déchiré', + 'journey.studio.edge.deckle': 'Bord frangé', + 'journey.studio.edgeSize': 'Profondeur', + 'journey.studio.edgeUnavailable': + 'Cette forme garde son contour dessiné. Les bords se découpent sur des formes larges comme les rectangles, les cercles et les arches.', + 'journey.studio.wobble': 'À main levée', + 'journey.studio.lineCourseTitle': 'Tracé', + 'journey.studio.lineCourse.straight': 'Droit', + 'journey.studio.lineCourse.smooth': 'Courbe', + 'journey.studio.lineBodyTitle': 'Corps', + 'journey.studio.lineBody.none': 'Simple', + 'journey.studio.lineBody.wave': 'Vague', + 'journey.studio.lineBody.zigzag': 'Zigzag', // same-as-en + 'journey.studio.linePointsHint': + 'Faites glisser un point sur la page pour le déplacer. Tirez une petite poignée entre deux points pour en ajouter un, double-cliquez sur un point pour le retirer.', + 'journey.studio.lineEnds': 'Extrémités', + 'journey.studio.lineStart': 'Début', + 'journey.studio.lineEnd': 'Fin', + 'journey.studio.cap.none': 'Aucune extrémité', + 'journey.studio.cap.arrow': 'Flèche', + 'journey.studio.cap.open': 'Flèche ouverte', + 'journey.studio.cap.dot': 'Point', + 'journey.studio.cap.bar': 'Barre', + 'journey.studio.swapEnds': 'Inverser les extrémités', + 'journey.studio.ornamentTitle': 'Ornement', + 'journey.studio.ornament.none': 'Aucun', + 'journey.studio.ornament.diamond': 'Losange', + 'journey.studio.ornament.dot': 'Point', + 'journey.studio.ornament.star': 'Étoile', + 'journey.studio.ornament.sun': 'Soleil', + 'journey.studio.ornament.heart': 'Cœur', + 'journey.studio.addPoint': 'Tirez pour ajouter un point', + 'journey.studio.removePoint': 'Double-cliquez pour retirer', + 'assistant.card.book.finding.hairline': 'Une ligne trop fine pour être imprimée', + 'assistant.card.book.finding.edge_unused': 'Un bord que cette forme ne prend pas', +}; +export default studioShapes; diff --git a/shared/src/i18n/gr/index.ts b/shared/src/i18n/gr/index.ts index 60250d2d3..c0a37ffe0 100644 --- a/shared/src/i18n/gr/index.ts +++ b/shared/src/i18n/gr/index.ts @@ -64,6 +64,7 @@ import studioLayers from './studioLayers'; import studioPaint from './studioPaint'; import studioPhoto from './studioPhoto'; import studioStickers from './studioStickers'; +import studioShapes from './studioShapes'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -124,6 +125,7 @@ const locale = { ...studioPaint, ...studioStickers, ...studioPhoto, + ...studioShapes, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/gr/studioShapes.ts b/shared/src/i18n/gr/studioShapes.ts new file mode 100644 index 000000000..9a1f1c055 --- /dev/null +++ b/shared/src/i18n/gr/studioShapes.ts @@ -0,0 +1,70 @@ +import type { TranslationStrings } from '../types'; + +/** Studio's second shelf of shapes, cut edges and drawn lines, and their two print checks. */ +const studioShapes: TranslationStrings = { + 'journey.studio.shapeGroup.arches': 'Αψίδες', + 'journey.studio.shapeGroup.cutouts': 'Χαρτοκοπτική', + 'journey.studio.shapeGroup.stamps': 'Γραμματόσημα και σήματα', + 'journey.studio.shapeGroup.frames': 'Κορνίζες', + 'journey.studio.shapeGroup.ornaments': 'Διακοσμητικά', + 'journey.studio.drawnLines': 'Σχεδιασμένες γραμμές', + 'journey.studio.dividers': 'Διαχωριστικά', + 'journey.studio.linePreset.straight': 'Ευθεία γραμμή', + 'journey.studio.linePreset.arrow': 'Βέλος', + 'journey.studio.linePreset.double': 'Διπλό βέλος', + 'journey.studio.linePreset.dashed': 'Διακεκομμένη γραμμή', + 'journey.studio.linePreset.dotted': 'Γραμμή με τελείες', + 'journey.studio.linePreset.curved': 'Καμπύλο βέλος', + 'journey.studio.linePreset.wave': 'Κυματιστή γραμμή', + 'journey.studio.linePreset.zigzag': 'Ζιγκ ζαγκ γραμμή', + 'journey.studio.linePreset.hand': 'Βέλος με το χέρι', + 'journey.studio.linePreset.route': 'Διαδρομή με τελείες', + 'journey.studio.linePreset.divider-diamond': 'Γραμμή με ρόμβο', + 'journey.studio.linePreset.divider-sun': 'Γραμμή με ήλιο', + 'journey.studio.linePreset.divider-star': 'Γραμμή με αστέρι', + 'journey.studio.linePreset.divider-heart': 'Γραμμή με καρδιά', + 'journey.studio.linePreset.divider-dot': 'Γραμμή με τελεία', + 'journey.studio.kind.line': 'Γραμμή', + 'journey.studio.edgeStyle': 'Άκρη', + 'journey.studio.edge.straight': 'Ευθεία', + 'journey.studio.edge.zigzag': 'Ζιγκ ζαγκ', + 'journey.studio.edge.scallop': 'Οδοντωτή σε τόξα', + 'journey.studio.edge.perforated': 'Διάτρητη', + 'journey.studio.edge.wavy': 'Κυματιστή', + 'journey.studio.edge.torn': 'Σκισμένη', + 'journey.studio.edge.deckle': 'Άκρη χειροποίητου χαρτιού', + 'journey.studio.edgeSize': 'Βάθος', + 'journey.studio.edgeUnavailable': + 'Αυτό το σχήμα κρατά το σχεδιασμένο περίγραμμά του. Οι άκρες κόβονται σε φαρδιά σχήματα, όπως ορθογώνια, κύκλους και αψίδες.', + 'journey.studio.wobble': 'Με το χέρι', + 'journey.studio.lineCourseTitle': 'Πορεία', + 'journey.studio.lineCourse.straight': 'Ευθεία', + 'journey.studio.lineCourse.smooth': 'Καμπύλη', + 'journey.studio.lineBodyTitle': 'Σώμα', + 'journey.studio.lineBody.none': 'Απλό', + 'journey.studio.lineBody.wave': 'Κύμα', + 'journey.studio.lineBody.zigzag': 'Ζιγκ ζαγκ', + 'journey.studio.linePointsHint': + 'Σύρετε ένα σημείο στη σελίδα για να το μετακινήσετε. Τραβήξτε μια μικρή λαβή ανάμεσα σε δύο σημεία για να προσθέσετε ένα, κάντε διπλό κλικ σε ένα σημείο για να το αφαιρέσετε.', + 'journey.studio.lineEnds': 'Άκρα', + 'journey.studio.lineStart': 'Αρχή', + 'journey.studio.lineEnd': 'Τέλος', + 'journey.studio.cap.none': 'Χωρίς άκρο', + 'journey.studio.cap.arrow': 'Βέλος', + 'journey.studio.cap.open': 'Ανοιχτό βέλος', + 'journey.studio.cap.dot': 'Τελεία', + 'journey.studio.cap.bar': 'Μπάρα', + 'journey.studio.swapEnds': 'Εναλλαγή άκρων', + 'journey.studio.ornamentTitle': 'Διακοσμητικό', + 'journey.studio.ornament.none': 'Κανένα', + 'journey.studio.ornament.diamond': 'Ρόμβος', + 'journey.studio.ornament.dot': 'Τελεία', + 'journey.studio.ornament.star': 'Αστέρι', + 'journey.studio.ornament.sun': 'Ήλιος', + 'journey.studio.ornament.heart': 'Καρδιά', + 'journey.studio.addPoint': 'Τραβήξτε για να προσθέσετε σημείο', + 'journey.studio.removePoint': 'Διπλό κλικ για αφαίρεση', + 'assistant.card.book.finding.hairline': 'Γραμμή πολύ λεπτή για εκτύπωση', + 'assistant.card.book.finding.edge_unused': 'Άκρη που αυτό το σχήμα δεν δέχεται', +}; +export default studioShapes; diff --git a/shared/src/i18n/hu/index.ts b/shared/src/i18n/hu/index.ts index 1777741e6..1233ddef7 100644 --- a/shared/src/i18n/hu/index.ts +++ b/shared/src/i18n/hu/index.ts @@ -64,6 +64,7 @@ import studioLayers from './studioLayers'; import studioPaint from './studioPaint'; import studioPhoto from './studioPhoto'; import studioStickers from './studioStickers'; +import studioShapes from './studioShapes'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -124,6 +125,7 @@ const locale = { ...studioPaint, ...studioStickers, ...studioPhoto, + ...studioShapes, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/hu/studioShapes.ts b/shared/src/i18n/hu/studioShapes.ts new file mode 100644 index 000000000..0ce060fa6 --- /dev/null +++ b/shared/src/i18n/hu/studioShapes.ts @@ -0,0 +1,70 @@ +import type { TranslationStrings } from '../types'; + +/** Studio's second shelf of shapes, cut edges and drawn lines, and their two print checks. */ +const studioShapes: TranslationStrings = { + 'journey.studio.shapeGroup.arches': 'Ívek', + 'journey.studio.shapeGroup.cutouts': 'Kivágások', + 'journey.studio.shapeGroup.stamps': 'Bélyegek és jelvények', + 'journey.studio.shapeGroup.frames': 'Keretek', + 'journey.studio.shapeGroup.ornaments': 'Díszítések', + 'journey.studio.drawnLines': 'Rajzolt vonalak', + 'journey.studio.dividers': 'Elválasztók', + 'journey.studio.linePreset.straight': 'Egyenes vonal', + 'journey.studio.linePreset.arrow': 'Nyíl', + 'journey.studio.linePreset.double': 'Dupla nyíl', + 'journey.studio.linePreset.dashed': 'Szaggatott vonal', + 'journey.studio.linePreset.dotted': 'Pontozott vonal', + 'journey.studio.linePreset.curved': 'Ívelt nyíl', + 'journey.studio.linePreset.wave': 'Hullámvonal', + 'journey.studio.linePreset.zigzag': 'Cikkcakkos vonal', + 'journey.studio.linePreset.hand': 'Kézzel rajzolt nyíl', + 'journey.studio.linePreset.route': 'Pontozott útvonal', + 'journey.studio.linePreset.divider-diamond': 'Vonal rombusszal', + 'journey.studio.linePreset.divider-sun': 'Vonal nappal', + 'journey.studio.linePreset.divider-star': 'Vonal csillaggal', + 'journey.studio.linePreset.divider-heart': 'Vonal szívvel', + 'journey.studio.linePreset.divider-dot': 'Vonal ponttal', + 'journey.studio.kind.line': 'Vonal', + 'journey.studio.edgeStyle': 'Szél', + 'journey.studio.edge.straight': 'Egyenes', + 'journey.studio.edge.zigzag': 'Cikkcakk', + 'journey.studio.edge.scallop': 'Csipkézett', + 'journey.studio.edge.perforated': 'Perforált', + 'journey.studio.edge.wavy': 'Hullámos', + 'journey.studio.edge.torn': 'Szakadt', + 'journey.studio.edge.deckle': 'Merített szél', + 'journey.studio.edgeSize': 'Mélység', + 'journey.studio.edgeUnavailable': + 'Ez az alakzat megtartja a megrajzolt körvonalát. A szélek széles alakzatokon vághatók, például téglalapokon, köröken és íveken.', + 'journey.studio.wobble': 'Kézi', + 'journey.studio.lineCourseTitle': 'Lefutás', + 'journey.studio.lineCourse.straight': 'Egyenes', + 'journey.studio.lineCourse.smooth': 'Ívelt', + 'journey.studio.lineBodyTitle': 'Test', + 'journey.studio.lineBody.none': 'Sima', + 'journey.studio.lineBody.wave': 'Hullám', + 'journey.studio.lineBody.zigzag': 'Cikkcakk', + 'journey.studio.linePointsHint': + 'Húzz egy pontot az oldalon az áthelyezéséhez. Két pont között egy kis fogantyút húzva újat adsz hozzá, dupla kattintással egy pontot eltávolítasz.', + 'journey.studio.lineEnds': 'Végek', + 'journey.studio.lineStart': 'Kezdet', + 'journey.studio.lineEnd': 'Vég', + 'journey.studio.cap.none': 'Nincs vég', + 'journey.studio.cap.arrow': 'Nyíl', + 'journey.studio.cap.open': 'Nyitott nyíl', + 'journey.studio.cap.dot': 'Pont', + 'journey.studio.cap.bar': 'Vonalka', + 'journey.studio.swapEnds': 'Végek felcserélése', + 'journey.studio.ornamentTitle': 'Díszítés', + 'journey.studio.ornament.none': 'Nincs', + 'journey.studio.ornament.diamond': 'Rombusz', + 'journey.studio.ornament.dot': 'Pont', + 'journey.studio.ornament.star': 'Csillag', + 'journey.studio.ornament.sun': 'Nap', + 'journey.studio.ornament.heart': 'Szív', + 'journey.studio.addPoint': 'Húzd egy pont hozzáadásához', + 'journey.studio.removePoint': 'Dupla kattintás az eltávolításhoz', + 'assistant.card.book.finding.hairline': 'Túl vékony vonal a nyomtatáshoz', + 'assistant.card.book.finding.edge_unused': 'Ez az alakzat nem fogad ilyen szélt', +}; +export default studioShapes; diff --git a/shared/src/i18n/id/index.ts b/shared/src/i18n/id/index.ts index 60250d2d3..c0a37ffe0 100644 --- a/shared/src/i18n/id/index.ts +++ b/shared/src/i18n/id/index.ts @@ -64,6 +64,7 @@ import studioLayers from './studioLayers'; import studioPaint from './studioPaint'; import studioPhoto from './studioPhoto'; import studioStickers from './studioStickers'; +import studioShapes from './studioShapes'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -124,6 +125,7 @@ const locale = { ...studioPaint, ...studioStickers, ...studioPhoto, + ...studioShapes, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/id/studioShapes.ts b/shared/src/i18n/id/studioShapes.ts new file mode 100644 index 000000000..737c95080 --- /dev/null +++ b/shared/src/i18n/id/studioShapes.ts @@ -0,0 +1,70 @@ +import type { TranslationStrings } from '../types'; + +/** Studio's second shelf of shapes, cut edges and drawn lines, and their two print checks. */ +const studioShapes: TranslationStrings = { + 'journey.studio.shapeGroup.arches': 'Lengkungan', + 'journey.studio.shapeGroup.cutouts': 'Guntingan', + 'journey.studio.shapeGroup.stamps': 'Prangko dan lencana', + 'journey.studio.shapeGroup.frames': 'Bingkai', + 'journey.studio.shapeGroup.ornaments': 'Ornamen', + 'journey.studio.drawnLines': 'Garis gambar', + 'journey.studio.dividers': 'Pemisah', + 'journey.studio.linePreset.straight': 'Garis lurus', + 'journey.studio.linePreset.arrow': 'Panah', + 'journey.studio.linePreset.double': 'Panah ganda', + 'journey.studio.linePreset.dashed': 'Garis putus-putus', + 'journey.studio.linePreset.dotted': 'Garis titik-titik', + 'journey.studio.linePreset.curved': 'Panah melengkung', + 'journey.studio.linePreset.wave': 'Garis bergelombang', + 'journey.studio.linePreset.zigzag': 'Garis zigzag', + 'journey.studio.linePreset.hand': 'Panah gambar tangan', + 'journey.studio.linePreset.route': 'Rute titik-titik', + 'journey.studio.linePreset.divider-diamond': 'Garis dengan belah ketupat', + 'journey.studio.linePreset.divider-sun': 'Garis dengan matahari', + 'journey.studio.linePreset.divider-star': 'Garis dengan bintang', + 'journey.studio.linePreset.divider-heart': 'Garis dengan hati', + 'journey.studio.linePreset.divider-dot': 'Garis dengan titik', + 'journey.studio.kind.line': 'Garis', + 'journey.studio.edgeStyle': 'Tepi', + 'journey.studio.edge.straight': 'Lurus', + 'journey.studio.edge.zigzag': 'Zigzag', // same-as-en + 'journey.studio.edge.scallop': 'Berkerang', + 'journey.studio.edge.perforated': 'Berlubang', + 'journey.studio.edge.wavy': 'Bergelombang', + 'journey.studio.edge.torn': 'Sobek', + 'journey.studio.edge.deckle': 'Tepi kertas buatan tangan', + 'journey.studio.edgeSize': 'Kedalaman', + 'journey.studio.edgeUnavailable': + 'Bentuk ini mempertahankan garis tepi gambarnya. Tepi dipotong pada bentuk lebar seperti persegi panjang, lingkaran, dan lengkungan.', + 'journey.studio.wobble': 'Gambar tangan', + 'journey.studio.lineCourseTitle': 'Alur', + 'journey.studio.lineCourse.straight': 'Lurus', + 'journey.studio.lineCourse.smooth': 'Melengkung', + 'journey.studio.lineBodyTitle': 'Badan', + 'journey.studio.lineBody.none': 'Polos', + 'journey.studio.lineBody.wave': 'Gelombang', + 'journey.studio.lineBody.zigzag': 'Zigzag', // same-as-en + 'journey.studio.linePointsHint': + 'Seret titik di halaman untuk memindahkannya. Tarik pegangan kecil di antara dua titik untuk menambah titik, klik dua kali sebuah titik untuk menghapusnya.', + 'journey.studio.lineEnds': 'Ujung', + 'journey.studio.lineStart': 'Awal', + 'journey.studio.lineEnd': 'Akhir', + 'journey.studio.cap.none': 'Tanpa ujung', + 'journey.studio.cap.arrow': 'Panah', + 'journey.studio.cap.open': 'Panah terbuka', + 'journey.studio.cap.dot': 'Titik', + 'journey.studio.cap.bar': 'Batang', + 'journey.studio.swapEnds': 'Tukar ujung', + 'journey.studio.ornamentTitle': 'Ornamen', + 'journey.studio.ornament.none': 'Tidak ada', + 'journey.studio.ornament.diamond': 'Belah ketupat', + 'journey.studio.ornament.dot': 'Titik', + 'journey.studio.ornament.star': 'Bintang', + 'journey.studio.ornament.sun': 'Matahari', + 'journey.studio.ornament.heart': 'Hati', + 'journey.studio.addPoint': 'Tarik untuk menambah titik', + 'journey.studio.removePoint': 'Klik dua kali untuk menghapus', + 'assistant.card.book.finding.hairline': 'Garis terlalu tipis untuk dicetak', + 'assistant.card.book.finding.edge_unused': 'Tepi yang tidak diterima bentuk ini', +}; +export default studioShapes; diff --git a/shared/src/i18n/it/index.ts b/shared/src/i18n/it/index.ts index 60250d2d3..c0a37ffe0 100644 --- a/shared/src/i18n/it/index.ts +++ b/shared/src/i18n/it/index.ts @@ -64,6 +64,7 @@ import studioLayers from './studioLayers'; import studioPaint from './studioPaint'; import studioPhoto from './studioPhoto'; import studioStickers from './studioStickers'; +import studioShapes from './studioShapes'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -124,6 +125,7 @@ const locale = { ...studioPaint, ...studioStickers, ...studioPhoto, + ...studioShapes, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/it/studioShapes.ts b/shared/src/i18n/it/studioShapes.ts new file mode 100644 index 000000000..951f9f9bd --- /dev/null +++ b/shared/src/i18n/it/studioShapes.ts @@ -0,0 +1,70 @@ +import type { TranslationStrings } from '../types'; + +/** Studio's second shelf of shapes, cut edges and drawn lines, and their two print checks. */ +const studioShapes: TranslationStrings = { + 'journey.studio.shapeGroup.arches': 'Archi', + 'journey.studio.shapeGroup.cutouts': 'Ritagli', + 'journey.studio.shapeGroup.stamps': 'Francobolli e distintivi', + 'journey.studio.shapeGroup.frames': 'Cornici', + 'journey.studio.shapeGroup.ornaments': 'Ornamenti', + 'journey.studio.drawnLines': 'Linee disegnate', + 'journey.studio.dividers': 'Divisori', + 'journey.studio.linePreset.straight': 'Linea retta', + 'journey.studio.linePreset.arrow': 'Freccia', + 'journey.studio.linePreset.double': 'Doppia freccia', + 'journey.studio.linePreset.dashed': 'Linea tratteggiata', + 'journey.studio.linePreset.dotted': 'Linea punteggiata', + 'journey.studio.linePreset.curved': 'Freccia curva', + 'journey.studio.linePreset.wave': 'Linea ondulata', + 'journey.studio.linePreset.zigzag': 'Linea a zigzag', + 'journey.studio.linePreset.hand': 'Freccia a mano libera', + 'journey.studio.linePreset.route': 'Percorso punteggiato', + 'journey.studio.linePreset.divider-diamond': 'Filetto con un rombo', + 'journey.studio.linePreset.divider-sun': 'Filetto con un sole', + 'journey.studio.linePreset.divider-star': 'Filetto con una stella', + 'journey.studio.linePreset.divider-heart': 'Filetto con un cuore', + 'journey.studio.linePreset.divider-dot': 'Filetto con un punto', + 'journey.studio.kind.line': 'Linea', + 'journey.studio.edgeStyle': 'Bordo', + 'journey.studio.edge.straight': 'Dritto', + 'journey.studio.edge.zigzag': 'Zigzag', // same-as-en + 'journey.studio.edge.scallop': 'Smerlato', + 'journey.studio.edge.perforated': 'Perforato', + 'journey.studio.edge.wavy': 'Ondulato', + 'journey.studio.edge.torn': 'Strappato', + 'journey.studio.edge.deckle': 'Bordo a sbavatura', + 'journey.studio.edgeSize': 'Profondità', + 'journey.studio.edgeUnavailable': + 'Questa forma mantiene il suo contorno disegnato. I bordi si tagliano su forme ampie come rettangoli, cerchi e archi.', + 'journey.studio.wobble': 'A mano libera', + 'journey.studio.lineCourseTitle': 'Andamento', + 'journey.studio.lineCourse.straight': 'Dritto', + 'journey.studio.lineCourse.smooth': 'Curvo', + 'journey.studio.lineBodyTitle': 'Corpo', + 'journey.studio.lineBody.none': 'Semplice', + 'journey.studio.lineBody.wave': 'Onda', + 'journey.studio.lineBody.zigzag': 'Zigzag', // same-as-en + 'journey.studio.linePointsHint': + 'Trascina un punto sulla pagina per spostarlo. Tira una piccola maniglia tra due punti per aggiungerne uno, fai doppio clic su un punto per toglierlo.', + 'journey.studio.lineEnds': 'Estremità', + 'journey.studio.lineStart': 'Inizio', + 'journey.studio.lineEnd': 'Fine', + 'journey.studio.cap.none': 'Nessuna estremità', + 'journey.studio.cap.arrow': 'Freccia', + 'journey.studio.cap.open': 'Freccia aperta', + 'journey.studio.cap.dot': 'Punto', + 'journey.studio.cap.bar': 'Barra', + 'journey.studio.swapEnds': 'Scambia le estremità', + 'journey.studio.ornamentTitle': 'Ornamento', + 'journey.studio.ornament.none': 'Nessuno', + 'journey.studio.ornament.diamond': 'Rombo', + 'journey.studio.ornament.dot': 'Punto', + 'journey.studio.ornament.star': 'Stella', + 'journey.studio.ornament.sun': 'Sole', + 'journey.studio.ornament.heart': 'Cuore', + 'journey.studio.addPoint': 'Tira per aggiungere un punto', + 'journey.studio.removePoint': 'Doppio clic per togliere', + 'assistant.card.book.finding.hairline': 'Una linea troppo sottile per la stampa', + 'assistant.card.book.finding.edge_unused': 'Un bordo che questa forma non accetta', +}; +export default studioShapes; diff --git a/shared/src/i18n/ja/index.ts b/shared/src/i18n/ja/index.ts index 60250d2d3..c0a37ffe0 100644 --- a/shared/src/i18n/ja/index.ts +++ b/shared/src/i18n/ja/index.ts @@ -64,6 +64,7 @@ import studioLayers from './studioLayers'; import studioPaint from './studioPaint'; import studioPhoto from './studioPhoto'; import studioStickers from './studioStickers'; +import studioShapes from './studioShapes'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -124,6 +125,7 @@ const locale = { ...studioPaint, ...studioStickers, ...studioPhoto, + ...studioShapes, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/ja/studioShapes.ts b/shared/src/i18n/ja/studioShapes.ts new file mode 100644 index 000000000..50e92d267 --- /dev/null +++ b/shared/src/i18n/ja/studioShapes.ts @@ -0,0 +1,70 @@ +import type { TranslationStrings } from '../types'; + +/** Studio's second shelf of shapes, cut edges and drawn lines, and their two print checks. */ +const studioShapes: TranslationStrings = { + 'journey.studio.shapeGroup.arches': 'アーチ', + 'journey.studio.shapeGroup.cutouts': '切り絵', + 'journey.studio.shapeGroup.stamps': '切手とバッジ', + 'journey.studio.shapeGroup.frames': 'フレーム', + 'journey.studio.shapeGroup.ornaments': '装飾', + 'journey.studio.drawnLines': '描いた線', + 'journey.studio.dividers': '区切り線', + 'journey.studio.linePreset.straight': '直線', + 'journey.studio.linePreset.arrow': '矢印', + 'journey.studio.linePreset.double': '両矢印', + 'journey.studio.linePreset.dashed': '破線', + 'journey.studio.linePreset.dotted': '点線', + 'journey.studio.linePreset.curved': '曲がった矢印', + 'journey.studio.linePreset.wave': '波線', + 'journey.studio.linePreset.zigzag': 'ジグザグ線', + 'journey.studio.linePreset.hand': '手描きの矢印', + 'journey.studio.linePreset.route': '点線のルート', + 'journey.studio.linePreset.divider-diamond': 'ひし形付きの罫線', + 'journey.studio.linePreset.divider-sun': '太陽付きの罫線', + 'journey.studio.linePreset.divider-star': '星付きの罫線', + 'journey.studio.linePreset.divider-heart': 'ハート付きの罫線', + 'journey.studio.linePreset.divider-dot': '点付きの罫線', + 'journey.studio.kind.line': '線', + 'journey.studio.edgeStyle': '縁', + 'journey.studio.edge.straight': 'まっすぐ', + 'journey.studio.edge.zigzag': 'ジグザグ', + 'journey.studio.edge.scallop': 'スカラップ', + 'journey.studio.edge.perforated': 'ミシン目', + 'journey.studio.edge.wavy': '波形', + 'journey.studio.edge.torn': 'ちぎり', + 'journey.studio.edge.deckle': '耳付き和紙', + 'journey.studio.edgeSize': '深さ', + 'journey.studio.edgeUnavailable': + 'この図形は描かれた輪郭のままです。縁は長方形、円、アーチなど幅のある図形に沿って切り抜けます。', + 'journey.studio.wobble': '手描き風', + 'journey.studio.lineCourseTitle': '道筋', + 'journey.studio.lineCourse.straight': 'まっすぐ', + 'journey.studio.lineCourse.smooth': '曲線', + 'journey.studio.lineBodyTitle': '本体', + 'journey.studio.lineBody.none': 'なし', + 'journey.studio.lineBody.wave': '波', + 'journey.studio.lineBody.zigzag': 'ジグザグ', + 'journey.studio.linePointsHint': + 'ページ上の点をドラッグすると移動します。2つの点の間の小さなハンドルを引くと点を追加し、点をダブルクリックすると削除します。', + 'journey.studio.lineEnds': '端', + 'journey.studio.lineStart': '始点', + 'journey.studio.lineEnd': '終点', + 'journey.studio.cap.none': '端なし', + 'journey.studio.cap.arrow': '矢印', + 'journey.studio.cap.open': '開いた矢印', + 'journey.studio.cap.dot': '点', + 'journey.studio.cap.bar': 'バー', + 'journey.studio.swapEnds': '端を入れ替え', + 'journey.studio.ornamentTitle': '装飾', + 'journey.studio.ornament.none': 'なし', + 'journey.studio.ornament.diamond': 'ひし形', + 'journey.studio.ornament.dot': '点', + 'journey.studio.ornament.star': '星', + 'journey.studio.ornament.sun': '太陽', + 'journey.studio.ornament.heart': 'ハート', + 'journey.studio.addPoint': '引いて点を追加', + 'journey.studio.removePoint': 'ダブルクリックで削除', + 'assistant.card.book.finding.hairline': '印刷するには細すぎる線', + 'assistant.card.book.finding.edge_unused': 'この図形では使えない縁', +}; +export default studioShapes; diff --git a/shared/src/i18n/ko/index.ts b/shared/src/i18n/ko/index.ts index 60250d2d3..c0a37ffe0 100644 --- a/shared/src/i18n/ko/index.ts +++ b/shared/src/i18n/ko/index.ts @@ -64,6 +64,7 @@ import studioLayers from './studioLayers'; import studioPaint from './studioPaint'; import studioPhoto from './studioPhoto'; import studioStickers from './studioStickers'; +import studioShapes from './studioShapes'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -124,6 +125,7 @@ const locale = { ...studioPaint, ...studioStickers, ...studioPhoto, + ...studioShapes, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/ko/studioShapes.ts b/shared/src/i18n/ko/studioShapes.ts new file mode 100644 index 000000000..c56e25624 --- /dev/null +++ b/shared/src/i18n/ko/studioShapes.ts @@ -0,0 +1,70 @@ +import type { TranslationStrings } from '../types'; + +/** Studio's second shelf of shapes, cut edges and drawn lines, and their two print checks. */ +const studioShapes: TranslationStrings = { + 'journey.studio.shapeGroup.arches': '아치', + 'journey.studio.shapeGroup.cutouts': '종이 오리기', + 'journey.studio.shapeGroup.stamps': '우표와 배지', + 'journey.studio.shapeGroup.frames': '프레임', + 'journey.studio.shapeGroup.ornaments': '장식', + 'journey.studio.drawnLines': '그린 선', + 'journey.studio.dividers': '구분선', + 'journey.studio.linePreset.straight': '직선', + 'journey.studio.linePreset.arrow': '화살표', + 'journey.studio.linePreset.double': '양방향 화살표', + 'journey.studio.linePreset.dashed': '파선', + 'journey.studio.linePreset.dotted': '점선', + 'journey.studio.linePreset.curved': '곡선 화살표', + 'journey.studio.linePreset.wave': '물결선', + 'journey.studio.linePreset.zigzag': '지그재그 선', + 'journey.studio.linePreset.hand': '손으로 그린 화살표', + 'journey.studio.linePreset.route': '점선 경로', + 'journey.studio.linePreset.divider-diamond': '마름모가 있는 선', + 'journey.studio.linePreset.divider-sun': '해가 있는 선', + 'journey.studio.linePreset.divider-star': '별이 있는 선', + 'journey.studio.linePreset.divider-heart': '하트가 있는 선', + 'journey.studio.linePreset.divider-dot': '점이 있는 선', + 'journey.studio.kind.line': '선', + 'journey.studio.edgeStyle': '가장자리', + 'journey.studio.edge.straight': '직선', + 'journey.studio.edge.zigzag': '지그재그', + 'journey.studio.edge.scallop': '물결 테두리', + 'journey.studio.edge.perforated': '절취선', + 'journey.studio.edge.wavy': '물결', + 'journey.studio.edge.torn': '찢김', + 'journey.studio.edge.deckle': '수제 종이 가장자리', + 'journey.studio.edgeSize': '깊이', + 'journey.studio.edgeUnavailable': + '이 도형은 그려진 윤곽을 유지합니다. 가장자리는 직사각형, 원, 아치처럼 넓은 도형을 따라 잘립니다.', + 'journey.studio.wobble': '손그림', + 'journey.studio.lineCourseTitle': '경로', + 'journey.studio.lineCourse.straight': '직선', + 'journey.studio.lineCourse.smooth': '곡선', + 'journey.studio.lineBodyTitle': '몸체', + 'journey.studio.lineBody.none': '기본', + 'journey.studio.lineBody.wave': '물결', + 'journey.studio.lineBody.zigzag': '지그재그', + 'journey.studio.linePointsHint': + '페이지에서 점을 끌어 옮기세요. 두 점 사이의 작은 핸들을 당기면 점이 추가되고, 점을 두 번 클릭하면 제거됩니다.', + 'journey.studio.lineEnds': '끝', + 'journey.studio.lineStart': '시작', + 'journey.studio.lineEnd': '끝점', + 'journey.studio.cap.none': '끝 없음', + 'journey.studio.cap.arrow': '화살표', + 'journey.studio.cap.open': '열린 화살표', + 'journey.studio.cap.dot': '점', + 'journey.studio.cap.bar': '막대', + 'journey.studio.swapEnds': '양 끝 바꾸기', + 'journey.studio.ornamentTitle': '장식', + 'journey.studio.ornament.none': '없음', + 'journey.studio.ornament.diamond': '마름모', + 'journey.studio.ornament.dot': '점', + 'journey.studio.ornament.star': '별', + 'journey.studio.ornament.sun': '해', + 'journey.studio.ornament.heart': '하트', + 'journey.studio.addPoint': '당겨서 점 추가', + 'journey.studio.removePoint': '두 번 클릭해 제거', + 'assistant.card.book.finding.hairline': '인쇄하기에 너무 가는 선', + 'assistant.card.book.finding.edge_unused': '이 도형이 받지 않는 가장자리', +}; +export default studioShapes; diff --git a/shared/src/i18n/nl/index.ts b/shared/src/i18n/nl/index.ts index 60250d2d3..c0a37ffe0 100644 --- a/shared/src/i18n/nl/index.ts +++ b/shared/src/i18n/nl/index.ts @@ -64,6 +64,7 @@ import studioLayers from './studioLayers'; import studioPaint from './studioPaint'; import studioPhoto from './studioPhoto'; import studioStickers from './studioStickers'; +import studioShapes from './studioShapes'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -124,6 +125,7 @@ const locale = { ...studioPaint, ...studioStickers, ...studioPhoto, + ...studioShapes, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/nl/studioShapes.ts b/shared/src/i18n/nl/studioShapes.ts new file mode 100644 index 000000000..aa1fccf2f --- /dev/null +++ b/shared/src/i18n/nl/studioShapes.ts @@ -0,0 +1,70 @@ +import type { TranslationStrings } from '../types'; + +/** Studio's second shelf of shapes, cut edges and drawn lines, and their two print checks. */ +const studioShapes: TranslationStrings = { + 'journey.studio.shapeGroup.arches': 'Bogen', + 'journey.studio.shapeGroup.cutouts': 'Knipsels', + 'journey.studio.shapeGroup.stamps': 'Postzegels en badges', + 'journey.studio.shapeGroup.frames': 'Lijsten', + 'journey.studio.shapeGroup.ornaments': 'Ornamenten', + 'journey.studio.drawnLines': 'Getekende lijnen', + 'journey.studio.dividers': 'Scheidingslijnen', + 'journey.studio.linePreset.straight': 'Rechte lijn', + 'journey.studio.linePreset.arrow': 'Pijl', + 'journey.studio.linePreset.double': 'Dubbele pijl', + 'journey.studio.linePreset.dashed': 'Gestreepte lijn', + 'journey.studio.linePreset.dotted': 'Gestippelde lijn', + 'journey.studio.linePreset.curved': 'Gebogen pijl', + 'journey.studio.linePreset.wave': 'Golflijn', + 'journey.studio.linePreset.zigzag': 'Zigzaglijn', + 'journey.studio.linePreset.hand': 'Handgetekende pijl', + 'journey.studio.linePreset.route': 'Gestippelde route', + 'journey.studio.linePreset.divider-diamond': 'Lijn met een ruit', + 'journey.studio.linePreset.divider-sun': 'Lijn met een zon', + 'journey.studio.linePreset.divider-star': 'Lijn met een ster', + 'journey.studio.linePreset.divider-heart': 'Lijn met een hart', + 'journey.studio.linePreset.divider-dot': 'Lijn met een stip', + 'journey.studio.kind.line': 'Lijn', + 'journey.studio.edgeStyle': 'Rand', + 'journey.studio.edge.straight': 'Recht', + 'journey.studio.edge.zigzag': 'Zigzag', // same-as-en + 'journey.studio.edge.scallop': 'Geschulpt', + 'journey.studio.edge.perforated': 'Geperforeerd', + 'journey.studio.edge.wavy': 'Golvend', + 'journey.studio.edge.torn': 'Gescheurd', + 'journey.studio.edge.deckle': 'Scheprand', + 'journey.studio.edgeSize': 'Diepte', + 'journey.studio.edgeUnavailable': + 'Deze vorm houdt zijn getekende omtrek. Randen worden gesneden langs brede vormen zoals rechthoeken, cirkels en bogen.', + 'journey.studio.wobble': 'Uit de hand', + 'journey.studio.lineCourseTitle': 'Verloop', + 'journey.studio.lineCourse.straight': 'Recht', + 'journey.studio.lineCourse.smooth': 'Gebogen', + 'journey.studio.lineBodyTitle': 'Lijf', + 'journey.studio.lineBody.none': 'Gewoon', + 'journey.studio.lineBody.wave': 'Golf', + 'journey.studio.lineBody.zigzag': 'Zigzag', // same-as-en + 'journey.studio.linePointsHint': + 'Sleep een punt op de pagina om het te verplaatsen. Trek aan een kleine greep tussen twee punten om er een toe te voegen, dubbelklik op een punt om het te verwijderen.', + 'journey.studio.lineEnds': 'Uiteinden', + 'journey.studio.lineStart': 'Begin', + 'journey.studio.lineEnd': 'Einde', + 'journey.studio.cap.none': 'Geen uiteinde', + 'journey.studio.cap.arrow': 'Pijl', + 'journey.studio.cap.open': 'Open pijl', + 'journey.studio.cap.dot': 'Stip', + 'journey.studio.cap.bar': 'Balk', + 'journey.studio.swapEnds': 'Uiteinden omwisselen', + 'journey.studio.ornamentTitle': 'Ornament', // same-as-en + 'journey.studio.ornament.none': 'Geen', + 'journey.studio.ornament.diamond': 'Ruit', + 'journey.studio.ornament.dot': 'Stip', + 'journey.studio.ornament.star': 'Ster', + 'journey.studio.ornament.sun': 'Zon', + 'journey.studio.ornament.heart': 'Hart', + 'journey.studio.addPoint': 'Trek om een punt toe te voegen', + 'journey.studio.removePoint': 'Dubbelklik om te verwijderen', + 'assistant.card.book.finding.hairline': 'Een lijn te dun om te drukken', + 'assistant.card.book.finding.edge_unused': 'Een rand die deze vorm niet aanneemt', +}; +export default studioShapes; diff --git a/shared/src/i18n/pl/index.ts b/shared/src/i18n/pl/index.ts index 60250d2d3..c0a37ffe0 100644 --- a/shared/src/i18n/pl/index.ts +++ b/shared/src/i18n/pl/index.ts @@ -64,6 +64,7 @@ import studioLayers from './studioLayers'; import studioPaint from './studioPaint'; import studioPhoto from './studioPhoto'; import studioStickers from './studioStickers'; +import studioShapes from './studioShapes'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -124,6 +125,7 @@ const locale = { ...studioPaint, ...studioStickers, ...studioPhoto, + ...studioShapes, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/pl/studioShapes.ts b/shared/src/i18n/pl/studioShapes.ts new file mode 100644 index 000000000..132151b3a --- /dev/null +++ b/shared/src/i18n/pl/studioShapes.ts @@ -0,0 +1,70 @@ +import type { TranslationStrings } from '../types'; + +/** Studio's second shelf of shapes, cut edges and drawn lines, and their two print checks. */ +const studioShapes: TranslationStrings = { + 'journey.studio.shapeGroup.arches': 'Łuki', + 'journey.studio.shapeGroup.cutouts': 'Wycinanki', + 'journey.studio.shapeGroup.stamps': 'Znaczki i odznaki', + 'journey.studio.shapeGroup.frames': 'Ramki', + 'journey.studio.shapeGroup.ornaments': 'Ornamenty', + 'journey.studio.drawnLines': 'Rysowane linie', + 'journey.studio.dividers': 'Separatory', + 'journey.studio.linePreset.straight': 'Linia prosta', + 'journey.studio.linePreset.arrow': 'Strzałka', + 'journey.studio.linePreset.double': 'Podwójna strzałka', + 'journey.studio.linePreset.dashed': 'Linia przerywana', + 'journey.studio.linePreset.dotted': 'Linia kropkowana', + 'journey.studio.linePreset.curved': 'Zakrzywiona strzałka', + 'journey.studio.linePreset.wave': 'Linia falista', + 'journey.studio.linePreset.zigzag': 'Linia zygzakowata', + 'journey.studio.linePreset.hand': 'Strzałka rysowana ręcznie', + 'journey.studio.linePreset.route': 'Kropkowana trasa', + 'journey.studio.linePreset.divider-diamond': 'Linia z rombem', + 'journey.studio.linePreset.divider-sun': 'Linia ze słońcem', + 'journey.studio.linePreset.divider-star': 'Linia z gwiazdą', + 'journey.studio.linePreset.divider-heart': 'Linia z sercem', + 'journey.studio.linePreset.divider-dot': 'Linia z kropką', + 'journey.studio.kind.line': 'Linia', + 'journey.studio.edgeStyle': 'Krawędź', + 'journey.studio.edge.straight': 'Prosta', + 'journey.studio.edge.zigzag': 'Zygzak', + 'journey.studio.edge.scallop': 'Ząbkowana w łuki', + 'journey.studio.edge.perforated': 'Perforowana', + 'journey.studio.edge.wavy': 'Falista', + 'journey.studio.edge.torn': 'Podarta', + 'journey.studio.edge.deckle': 'Czerpany brzeg', + 'journey.studio.edgeSize': 'Głębokość', + 'journey.studio.edgeUnavailable': + 'Ten kształt zachowuje narysowany obrys. Krawędzie wycina się na szerokich kształtach, takich jak prostokąty, koła i łuki.', + 'journey.studio.wobble': 'Odręcznie', + 'journey.studio.lineCourseTitle': 'Przebieg', + 'journey.studio.lineCourse.straight': 'Prosty', + 'journey.studio.lineCourse.smooth': 'Zakrzywiony', + 'journey.studio.lineBodyTitle': 'Korpus', + 'journey.studio.lineBody.none': 'Zwykły', + 'journey.studio.lineBody.wave': 'Fala', + 'journey.studio.lineBody.zigzag': 'Zygzak', + 'journey.studio.linePointsHint': + 'Przeciągnij punkt na stronie, aby go przesunąć. Pociągnij mały uchwyt między dwoma punktami, aby dodać punkt, kliknij punkt dwukrotnie, aby go usunąć.', + 'journey.studio.lineEnds': 'Końce', + 'journey.studio.lineStart': 'Początek', + 'journey.studio.lineEnd': 'Koniec', + 'journey.studio.cap.none': 'Bez końcówki', + 'journey.studio.cap.arrow': 'Strzałka', + 'journey.studio.cap.open': 'Otwarta strzałka', + 'journey.studio.cap.dot': 'Kropka', + 'journey.studio.cap.bar': 'Belka', + 'journey.studio.swapEnds': 'Zamień końce', + 'journey.studio.ornamentTitle': 'Ornament', // same-as-en + 'journey.studio.ornament.none': 'Brak', + 'journey.studio.ornament.diamond': 'Romb', + 'journey.studio.ornament.dot': 'Kropka', + 'journey.studio.ornament.star': 'Gwiazda', + 'journey.studio.ornament.sun': 'Słońce', + 'journey.studio.ornament.heart': 'Serce', + 'journey.studio.addPoint': 'Pociągnij, aby dodać punkt', + 'journey.studio.removePoint': 'Kliknij dwukrotnie, aby usunąć', + 'assistant.card.book.finding.hairline': 'Linia zbyt cienka do druku', + 'assistant.card.book.finding.edge_unused': 'Krawędź, której ten kształt nie przyjmuje', +}; +export default studioShapes; diff --git a/shared/src/i18n/ru/index.ts b/shared/src/i18n/ru/index.ts index 60250d2d3..c0a37ffe0 100644 --- a/shared/src/i18n/ru/index.ts +++ b/shared/src/i18n/ru/index.ts @@ -64,6 +64,7 @@ import studioLayers from './studioLayers'; import studioPaint from './studioPaint'; import studioPhoto from './studioPhoto'; import studioStickers from './studioStickers'; +import studioShapes from './studioShapes'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -124,6 +125,7 @@ const locale = { ...studioPaint, ...studioStickers, ...studioPhoto, + ...studioShapes, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/ru/studioShapes.ts b/shared/src/i18n/ru/studioShapes.ts new file mode 100644 index 000000000..221393d69 --- /dev/null +++ b/shared/src/i18n/ru/studioShapes.ts @@ -0,0 +1,70 @@ +import type { TranslationStrings } from '../types'; + +/** Studio's second shelf of shapes, cut edges and drawn lines, and their two print checks. */ +const studioShapes: TranslationStrings = { + 'journey.studio.shapeGroup.arches': 'Арки', + 'journey.studio.shapeGroup.cutouts': 'Вырезки', + 'journey.studio.shapeGroup.stamps': 'Марки и значки', + 'journey.studio.shapeGroup.frames': 'Рамки', + 'journey.studio.shapeGroup.ornaments': 'Орнаменты', + 'journey.studio.drawnLines': 'Нарисованные линии', + 'journey.studio.dividers': 'Разделители', + 'journey.studio.linePreset.straight': 'Прямая линия', + 'journey.studio.linePreset.arrow': 'Стрелка', + 'journey.studio.linePreset.double': 'Двойная стрелка', + 'journey.studio.linePreset.dashed': 'Штриховая линия', + 'journey.studio.linePreset.dotted': 'Пунктирная линия', + 'journey.studio.linePreset.curved': 'Изогнутая стрелка', + 'journey.studio.linePreset.wave': 'Волнистая линия', + 'journey.studio.linePreset.zigzag': 'Зигзаг', + 'journey.studio.linePreset.hand': 'Стрелка от руки', + 'journey.studio.linePreset.route': 'Пунктирный маршрут', + 'journey.studio.linePreset.divider-diamond': 'Линейка с ромбом', + 'journey.studio.linePreset.divider-sun': 'Линейка с солнцем', + 'journey.studio.linePreset.divider-star': 'Линейка со звездой', + 'journey.studio.linePreset.divider-heart': 'Линейка с сердцем', + 'journey.studio.linePreset.divider-dot': 'Линейка с точкой', + 'journey.studio.kind.line': 'Линия', + 'journey.studio.edgeStyle': 'Край', + 'journey.studio.edge.straight': 'Прямой', + 'journey.studio.edge.zigzag': 'Зигзаг', + 'journey.studio.edge.scallop': 'Фестоны', + 'journey.studio.edge.perforated': 'Перфорация', + 'journey.studio.edge.wavy': 'Волнистый', + 'journey.studio.edge.torn': 'Рваный', + 'journey.studio.edge.deckle': 'Кромка ручной бумаги', + 'journey.studio.edgeSize': 'Глубина', + 'journey.studio.edgeUnavailable': + 'Эта фигура сохраняет нарисованный контур. Край вырезается у широких фигур: прямоугольников, кругов и арок.', + 'journey.studio.wobble': 'От руки', + 'journey.studio.lineCourseTitle': 'Ход', + 'journey.studio.lineCourse.straight': 'Прямой', + 'journey.studio.lineCourse.smooth': 'Изогнутый', + 'journey.studio.lineBodyTitle': 'Тело', + 'journey.studio.lineBody.none': 'Простое', + 'journey.studio.lineBody.wave': 'Волна', + 'journey.studio.lineBody.zigzag': 'Зигзаг', + 'journey.studio.linePointsHint': + 'Перетащите точку на странице, чтобы сдвинуть её. Потяните маленький маркер между двумя точками, чтобы добавить точку, дважды щёлкните по точке, чтобы удалить её.', + 'journey.studio.lineEnds': 'Концы', + 'journey.studio.lineStart': 'Начало', + 'journey.studio.lineEnd': 'Конец', + 'journey.studio.cap.none': 'Без конца', + 'journey.studio.cap.arrow': 'Стрелка', + 'journey.studio.cap.open': 'Открытая стрелка', + 'journey.studio.cap.dot': 'Точка', + 'journey.studio.cap.bar': 'Черта', + 'journey.studio.swapEnds': 'Поменять концы', + 'journey.studio.ornamentTitle': 'Орнамент', + 'journey.studio.ornament.none': 'Нет', + 'journey.studio.ornament.diamond': 'Ромб', + 'journey.studio.ornament.dot': 'Точка', + 'journey.studio.ornament.star': 'Звезда', + 'journey.studio.ornament.sun': 'Солнце', + 'journey.studio.ornament.heart': 'Сердце', + 'journey.studio.addPoint': 'Потяните, чтобы добавить точку', + 'journey.studio.removePoint': 'Двойной щелчок, чтобы удалить', + 'assistant.card.book.finding.hairline': 'Линия слишком тонкая для печати', + 'assistant.card.book.finding.edge_unused': 'Край, который эта фигура не принимает', +}; +export default studioShapes; diff --git a/shared/src/i18n/sk/index.ts b/shared/src/i18n/sk/index.ts index 57b825437..fd911c33d 100644 --- a/shared/src/i18n/sk/index.ts +++ b/shared/src/i18n/sk/index.ts @@ -64,6 +64,7 @@ import studioLayers from './studioLayers'; import studioPaint from './studioPaint'; import studioPhoto from './studioPhoto'; import studioStickers from './studioStickers'; +import studioShapes from './studioShapes'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -124,6 +125,7 @@ const locale = { ...studioPaint, ...studioStickers, ...studioPhoto, + ...studioShapes, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/sk/studioShapes.ts b/shared/src/i18n/sk/studioShapes.ts new file mode 100644 index 000000000..1437aab1c --- /dev/null +++ b/shared/src/i18n/sk/studioShapes.ts @@ -0,0 +1,70 @@ +import type { TranslationStrings } from '../types'; + +/** Studio's second shelf of shapes, cut edges and drawn lines, and their two print checks. */ +const studioShapes: TranslationStrings = { + 'journey.studio.shapeGroup.arches': 'Oblúky', + 'journey.studio.shapeGroup.cutouts': 'Vystrihovačky', + 'journey.studio.shapeGroup.stamps': 'Známky a odznaky', + 'journey.studio.shapeGroup.frames': 'Rámčeky', + 'journey.studio.shapeGroup.ornaments': 'Ornamenty', + 'journey.studio.drawnLines': 'Kreslené čiary', + 'journey.studio.dividers': 'Oddeľovače', + 'journey.studio.linePreset.straight': 'Rovná čiara', + 'journey.studio.linePreset.arrow': 'Šípka', + 'journey.studio.linePreset.double': 'Dvojitá šípka', + 'journey.studio.linePreset.dashed': 'Čiarkovaná čiara', + 'journey.studio.linePreset.dotted': 'Bodkovaná čiara', + 'journey.studio.linePreset.curved': 'Zahnutá šípka', + 'journey.studio.linePreset.wave': 'Vlnovka', + 'journey.studio.linePreset.zigzag': 'Kľukatá čiara', + 'journey.studio.linePreset.hand': 'Ručne kreslená šípka', + 'journey.studio.linePreset.route': 'Bodkovaná trasa', + 'journey.studio.linePreset.divider-diamond': 'Linka s kosoštvorcom', + 'journey.studio.linePreset.divider-sun': 'Linka so slnkom', + 'journey.studio.linePreset.divider-star': 'Linka s hviezdou', + 'journey.studio.linePreset.divider-heart': 'Linka so srdcom', + 'journey.studio.linePreset.divider-dot': 'Linka s bodkou', + 'journey.studio.kind.line': 'Čiara', + 'journey.studio.edgeStyle': 'Okraj', + 'journey.studio.edge.straight': 'Rovný', + 'journey.studio.edge.zigzag': 'Cik cak', + 'journey.studio.edge.scallop': 'Vrúbkovaný', + 'journey.studio.edge.perforated': 'Perforovaný', + 'journey.studio.edge.wavy': 'Vlnitý', + 'journey.studio.edge.torn': 'Natrhnutý', + 'journey.studio.edge.deckle': 'Ručný papier', + 'journey.studio.edgeSize': 'Hĺbka', + 'journey.studio.edgeUnavailable': + 'Tento tvar si ponechá nakreslený obrys. Okraje sa strihajú pozdĺž širokých tvarov, ako sú obdĺžniky, kruhy a oblúky.', + 'journey.studio.wobble': 'Od ruky', + 'journey.studio.lineCourseTitle': 'Priebeh', + 'journey.studio.lineCourse.straight': 'Rovný', + 'journey.studio.lineCourse.smooth': 'Zakrivený', + 'journey.studio.lineBodyTitle': 'Telo', + 'journey.studio.lineBody.none': 'Jednoduchý', + 'journey.studio.lineBody.wave': 'Vlna', + 'journey.studio.lineBody.zigzag': 'Cik cak', + 'journey.studio.linePointsHint': + 'Potiahnutím bodu na stránke ho posuniete. Potiahnite malý úchyt medzi dvoma bodmi a pridáte bod, dvojklikom na bod ho odstránite.', + 'journey.studio.lineEnds': 'Konce', + 'journey.studio.lineStart': 'Začiatok', + 'journey.studio.lineEnd': 'Koniec', + 'journey.studio.cap.none': 'Bez konca', + 'journey.studio.cap.arrow': 'Šípka', + 'journey.studio.cap.open': 'Otvorená šípka', + 'journey.studio.cap.dot': 'Bodka', + 'journey.studio.cap.bar': 'Priečka', + 'journey.studio.swapEnds': 'Vymeniť konce', + 'journey.studio.ornamentTitle': 'Ornament', // same-as-en + 'journey.studio.ornament.none': 'Žiadny', + 'journey.studio.ornament.diamond': 'Kosoštvorec', + 'journey.studio.ornament.dot': 'Bodka', + 'journey.studio.ornament.star': 'Hviezda', + 'journey.studio.ornament.sun': 'Slnko', + 'journey.studio.ornament.heart': 'Srdce', + 'journey.studio.addPoint': 'Potiahnite a pridajte bod', + 'journey.studio.removePoint': 'Dvojklikom odstránite', + 'assistant.card.book.finding.hairline': 'Čiara príliš tenká na tlač', + 'assistant.card.book.finding.edge_unused': 'Okraj, ktorý tento tvar neprijme', +}; +export default studioShapes; diff --git a/shared/src/i18n/sv/index.ts b/shared/src/i18n/sv/index.ts index 60250d2d3..c0a37ffe0 100644 --- a/shared/src/i18n/sv/index.ts +++ b/shared/src/i18n/sv/index.ts @@ -64,6 +64,7 @@ import studioLayers from './studioLayers'; import studioPaint from './studioPaint'; import studioPhoto from './studioPhoto'; import studioStickers from './studioStickers'; +import studioShapes from './studioShapes'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -124,6 +125,7 @@ const locale = { ...studioPaint, ...studioStickers, ...studioPhoto, + ...studioShapes, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/sv/studioShapes.ts b/shared/src/i18n/sv/studioShapes.ts new file mode 100644 index 000000000..adcbed5a4 --- /dev/null +++ b/shared/src/i18n/sv/studioShapes.ts @@ -0,0 +1,70 @@ +import type { TranslationStrings } from '../types'; + +/** Studio's second shelf of shapes, cut edges and drawn lines, and their two print checks. */ +const studioShapes: TranslationStrings = { + 'journey.studio.shapeGroup.arches': 'Valv', + 'journey.studio.shapeGroup.cutouts': 'Urklipp', + 'journey.studio.shapeGroup.stamps': 'Frimärken och märken', + 'journey.studio.shapeGroup.frames': 'Ramar', + 'journey.studio.shapeGroup.ornaments': 'Ornament', + 'journey.studio.drawnLines': 'Ritade linjer', + 'journey.studio.dividers': 'Avdelare', + 'journey.studio.linePreset.straight': 'Rak linje', + 'journey.studio.linePreset.arrow': 'Pil', + 'journey.studio.linePreset.double': 'Dubbelpil', + 'journey.studio.linePreset.dashed': 'Streckad linje', + 'journey.studio.linePreset.dotted': 'Prickad linje', + 'journey.studio.linePreset.curved': 'Böjd pil', + 'journey.studio.linePreset.wave': 'Våglinje', + 'journey.studio.linePreset.zigzag': 'Sicksacklinje', + 'journey.studio.linePreset.hand': 'Handritad pil', + 'journey.studio.linePreset.route': 'Prickad rutt', + 'journey.studio.linePreset.divider-diamond': 'Linje med en romb', + 'journey.studio.linePreset.divider-sun': 'Linje med en sol', + 'journey.studio.linePreset.divider-star': 'Linje med en stjärna', + 'journey.studio.linePreset.divider-heart': 'Linje med ett hjärta', + 'journey.studio.linePreset.divider-dot': 'Linje med en prick', + 'journey.studio.kind.line': 'Linje', + 'journey.studio.edgeStyle': 'Kant', + 'journey.studio.edge.straight': 'Rak', + 'journey.studio.edge.zigzag': 'Sicksack', + 'journey.studio.edge.scallop': 'Bågad', + 'journey.studio.edge.perforated': 'Perforerad', + 'journey.studio.edge.wavy': 'Vågig', + 'journey.studio.edge.torn': 'Riven', + 'journey.studio.edge.deckle': 'Bräddad kant', + 'journey.studio.edgeSize': 'Djup', + 'journey.studio.edgeUnavailable': + 'Den här formen behåller sin ritade kontur. Kanter klipps längs breda former som rektanglar, cirklar och valv.', + 'journey.studio.wobble': 'Handritad', + 'journey.studio.lineCourseTitle': 'Förlopp', + 'journey.studio.lineCourse.straight': 'Rak', + 'journey.studio.lineCourse.smooth': 'Böjd', + 'journey.studio.lineBodyTitle': 'Kropp', + 'journey.studio.lineBody.none': 'Enkel', + 'journey.studio.lineBody.wave': 'Våg', + 'journey.studio.lineBody.zigzag': 'Sicksack', + 'journey.studio.linePointsHint': + 'Dra en punkt på sidan för att flytta den. Dra i ett litet handtag mellan två punkter för att lägga till en, dubbelklicka på en punkt för att ta bort den.', + 'journey.studio.lineEnds': 'Ändar', + 'journey.studio.lineStart': 'Början', + 'journey.studio.lineEnd': 'Slut', + 'journey.studio.cap.none': 'Ingen ände', + 'journey.studio.cap.arrow': 'Pil', + 'journey.studio.cap.open': 'Öppen pil', + 'journey.studio.cap.dot': 'Prick', + 'journey.studio.cap.bar': 'Stapel', + 'journey.studio.swapEnds': 'Byt ändar', + 'journey.studio.ornamentTitle': 'Ornament', // same-as-en + 'journey.studio.ornament.none': 'Ingen', + 'journey.studio.ornament.diamond': 'Romb', + 'journey.studio.ornament.dot': 'Prick', + 'journey.studio.ornament.star': 'Stjärna', + 'journey.studio.ornament.sun': 'Sol', + 'journey.studio.ornament.heart': 'Hjärta', + 'journey.studio.addPoint': 'Dra för att lägga till en punkt', + 'journey.studio.removePoint': 'Dubbelklicka för att ta bort', + 'assistant.card.book.finding.hairline': 'En linje för tunn för att tryckas', + 'assistant.card.book.finding.edge_unused': 'En kant som den här formen inte tar', +}; +export default studioShapes; diff --git a/shared/src/i18n/th/index.ts b/shared/src/i18n/th/index.ts index 57b825437..fd911c33d 100644 --- a/shared/src/i18n/th/index.ts +++ b/shared/src/i18n/th/index.ts @@ -64,6 +64,7 @@ import studioLayers from './studioLayers'; import studioPaint from './studioPaint'; import studioPhoto from './studioPhoto'; import studioStickers from './studioStickers'; +import studioShapes from './studioShapes'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -124,6 +125,7 @@ const locale = { ...studioPaint, ...studioStickers, ...studioPhoto, + ...studioShapes, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/th/studioShapes.ts b/shared/src/i18n/th/studioShapes.ts new file mode 100644 index 000000000..e18833fbf --- /dev/null +++ b/shared/src/i18n/th/studioShapes.ts @@ -0,0 +1,70 @@ +import type { TranslationStrings } from '../types'; + +/** Studio's second shelf of shapes, cut edges and drawn lines, and their two print checks. */ +const studioShapes: TranslationStrings = { + 'journey.studio.shapeGroup.arches': 'ซุ้มโค้ง', + 'journey.studio.shapeGroup.cutouts': 'กระดาษตัด', + 'journey.studio.shapeGroup.stamps': 'แสตมป์และตรา', + 'journey.studio.shapeGroup.frames': 'กรอบ', + 'journey.studio.shapeGroup.ornaments': 'ลวดลาย', + 'journey.studio.drawnLines': 'เส้นวาด', + 'journey.studio.dividers': 'เส้นคั่น', + 'journey.studio.linePreset.straight': 'เส้นตรง', + 'journey.studio.linePreset.arrow': 'ลูกศร', + 'journey.studio.linePreset.double': 'ลูกศรสองทาง', + 'journey.studio.linePreset.dashed': 'เส้นประ', + 'journey.studio.linePreset.dotted': 'เส้นจุด', + 'journey.studio.linePreset.curved': 'ลูกศรโค้ง', + 'journey.studio.linePreset.wave': 'เส้นคลื่น', + 'journey.studio.linePreset.zigzag': 'เส้นซิกแซก', + 'journey.studio.linePreset.hand': 'ลูกศรวาดมือ', + 'journey.studio.linePreset.route': 'เส้นทางจุด', + 'journey.studio.linePreset.divider-diamond': 'เส้นคั่นมีรูปข้าวหลามตัด', + 'journey.studio.linePreset.divider-sun': 'เส้นคั่นมีดวงอาทิตย์', + 'journey.studio.linePreset.divider-star': 'เส้นคั่นมีดาว', + 'journey.studio.linePreset.divider-heart': 'เส้นคั่นมีหัวใจ', + 'journey.studio.linePreset.divider-dot': 'เส้นคั่นมีจุด', + 'journey.studio.kind.line': 'เส้น', + 'journey.studio.edgeStyle': 'ขอบ', + 'journey.studio.edge.straight': 'ตรง', + 'journey.studio.edge.zigzag': 'ซิกแซก', + 'journey.studio.edge.scallop': 'หยักโค้ง', + 'journey.studio.edge.perforated': 'รอยปรุ', + 'journey.studio.edge.wavy': 'คลื่น', + 'journey.studio.edge.torn': 'ฉีก', + 'journey.studio.edge.deckle': 'ขอบกระดาษทำมือ', + 'journey.studio.edgeSize': 'ความลึก', + 'journey.studio.edgeUnavailable': + 'รูปทรงนี้คงเส้นขอบที่วาดไว้ ขอบตัดได้กับรูปทรงกว้าง เช่น สี่เหลี่ยมผืนผ้า วงกลม และซุ้มโค้ง', + 'journey.studio.wobble': 'วาดมือ', + 'journey.studio.lineCourseTitle': 'แนวเส้น', + 'journey.studio.lineCourse.straight': 'ตรง', + 'journey.studio.lineCourse.smooth': 'โค้ง', + 'journey.studio.lineBodyTitle': 'ตัวเส้น', + 'journey.studio.lineBody.none': 'เรียบ', + 'journey.studio.lineBody.wave': 'คลื่น', + 'journey.studio.lineBody.zigzag': 'ซิกแซก', + 'journey.studio.linePointsHint': + 'ลากจุดบนหน้าเพื่อย้าย ดึงที่จับเล็กระหว่างสองจุดเพื่อเพิ่มจุด ดับเบิลคลิกที่จุดเพื่อลบ', + 'journey.studio.lineEnds': 'ปลายเส้น', + 'journey.studio.lineStart': 'จุดเริ่ม', + 'journey.studio.lineEnd': 'จุดจบ', + 'journey.studio.cap.none': 'ไม่มีปลาย', + 'journey.studio.cap.arrow': 'ลูกศร', + 'journey.studio.cap.open': 'ลูกศรเปิด', + 'journey.studio.cap.dot': 'จุด', + 'journey.studio.cap.bar': 'ขีด', + 'journey.studio.swapEnds': 'สลับปลาย', + 'journey.studio.ornamentTitle': 'ลวดลาย', + 'journey.studio.ornament.none': 'ไม่มี', + 'journey.studio.ornament.diamond': 'ข้าวหลามตัด', + 'journey.studio.ornament.dot': 'จุด', + 'journey.studio.ornament.star': 'ดาว', + 'journey.studio.ornament.sun': 'ดวงอาทิตย์', + 'journey.studio.ornament.heart': 'หัวใจ', + 'journey.studio.addPoint': 'ดึงเพื่อเพิ่มจุด', + 'journey.studio.removePoint': 'ดับเบิลคลิกเพื่อลบ', + 'assistant.card.book.finding.hairline': 'เส้นบางเกินกว่าจะพิมพ์ได้', + 'assistant.card.book.finding.edge_unused': 'ขอบที่รูปทรงนี้ใช้ไม่ได้', +}; +export default studioShapes; diff --git a/shared/src/i18n/tr/index.ts b/shared/src/i18n/tr/index.ts index 60250d2d3..c0a37ffe0 100644 --- a/shared/src/i18n/tr/index.ts +++ b/shared/src/i18n/tr/index.ts @@ -64,6 +64,7 @@ import studioLayers from './studioLayers'; import studioPaint from './studioPaint'; import studioPhoto from './studioPhoto'; import studioStickers from './studioStickers'; +import studioShapes from './studioShapes'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -124,6 +125,7 @@ const locale = { ...studioPaint, ...studioStickers, ...studioPhoto, + ...studioShapes, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/tr/studioShapes.ts b/shared/src/i18n/tr/studioShapes.ts new file mode 100644 index 000000000..1738b0cf2 --- /dev/null +++ b/shared/src/i18n/tr/studioShapes.ts @@ -0,0 +1,70 @@ +import type { TranslationStrings } from '../types'; + +/** Studio's second shelf of shapes, cut edges and drawn lines, and their two print checks. */ +const studioShapes: TranslationStrings = { + 'journey.studio.shapeGroup.arches': 'Kemerler', + 'journey.studio.shapeGroup.cutouts': 'Kesik kâğıtlar', + 'journey.studio.shapeGroup.stamps': 'Pullar ve rozetler', + 'journey.studio.shapeGroup.frames': 'Çerçeveler', + 'journey.studio.shapeGroup.ornaments': 'Süslemeler', + 'journey.studio.drawnLines': 'Çizilmiş çizgiler', + 'journey.studio.dividers': 'Ayırıcılar', + 'journey.studio.linePreset.straight': 'Düz çizgi', + 'journey.studio.linePreset.arrow': 'Ok', + 'journey.studio.linePreset.double': 'Çift ok', + 'journey.studio.linePreset.dashed': 'Kesikli çizgi', + 'journey.studio.linePreset.dotted': 'Noktalı çizgi', + 'journey.studio.linePreset.curved': 'Kavisli ok', + 'journey.studio.linePreset.wave': 'Dalgalı çizgi', + 'journey.studio.linePreset.zigzag': 'Zikzak çizgi', + 'journey.studio.linePreset.hand': 'Elle çizilmiş ok', + 'journey.studio.linePreset.route': 'Noktalı rota', + 'journey.studio.linePreset.divider-diamond': 'Baklavalı çizgi', + 'journey.studio.linePreset.divider-sun': 'Güneşli çizgi', + 'journey.studio.linePreset.divider-star': 'Yıldızlı çizgi', + 'journey.studio.linePreset.divider-heart': 'Kalpli çizgi', + 'journey.studio.linePreset.divider-dot': 'Noktalı ara çizgi', + 'journey.studio.kind.line': 'Çizgi', + 'journey.studio.edgeStyle': 'Kenar', + 'journey.studio.edge.straight': 'Düz', + 'journey.studio.edge.zigzag': 'Zikzak', + 'journey.studio.edge.scallop': 'Fistolu', + 'journey.studio.edge.perforated': 'Delikli', + 'journey.studio.edge.wavy': 'Dalgalı', + 'journey.studio.edge.torn': 'Yırtık', + 'journey.studio.edge.deckle': 'El yapımı kâğıt kenarı', + 'journey.studio.edgeSize': 'Derinlik', + 'journey.studio.edgeUnavailable': + 'Bu şekil çizilmiş dış hattını korur. Kenarlar dikdörtgen, daire ve kemer gibi geniş şekiller boyunca kesilir.', + 'journey.studio.wobble': 'Elle çizim', + 'journey.studio.lineCourseTitle': 'Gidiş', + 'journey.studio.lineCourse.straight': 'Düz', + 'journey.studio.lineCourse.smooth': 'Kavisli', + 'journey.studio.lineBodyTitle': 'Gövde', + 'journey.studio.lineBody.none': 'Sade', + 'journey.studio.lineBody.wave': 'Dalga', + 'journey.studio.lineBody.zigzag': 'Zikzak', + 'journey.studio.linePointsHint': + 'Taşımak için sayfadaki bir noktayı sürükleyin. Nokta eklemek için iki nokta arasındaki küçük tutamacı çekin, kaldırmak için noktaya çift tıklayın.', + 'journey.studio.lineEnds': 'Uçlar', + 'journey.studio.lineStart': 'Başlangıç', + 'journey.studio.lineEnd': 'Bitiş', + 'journey.studio.cap.none': 'Uç yok', + 'journey.studio.cap.arrow': 'Ok', + 'journey.studio.cap.open': 'Açık ok', + 'journey.studio.cap.dot': 'Nokta', + 'journey.studio.cap.bar': 'Çubuk', + 'journey.studio.swapEnds': 'Uçları değiştir', + 'journey.studio.ornamentTitle': 'Süsleme', + 'journey.studio.ornament.none': 'Yok', + 'journey.studio.ornament.diamond': 'Baklava', + 'journey.studio.ornament.dot': 'Nokta', + 'journey.studio.ornament.star': 'Yıldız', + 'journey.studio.ornament.sun': 'Güneş', + 'journey.studio.ornament.heart': 'Kalp', + 'journey.studio.addPoint': 'Nokta eklemek için çekin', + 'journey.studio.removePoint': 'Kaldırmak için çift tıklayın', + 'assistant.card.book.finding.hairline': 'Basılamayacak kadar ince bir çizgi', + 'assistant.card.book.finding.edge_unused': 'Bu şeklin almadığı bir kenar', +}; +export default studioShapes; diff --git a/shared/src/i18n/uk/index.ts b/shared/src/i18n/uk/index.ts index 60250d2d3..c0a37ffe0 100644 --- a/shared/src/i18n/uk/index.ts +++ b/shared/src/i18n/uk/index.ts @@ -64,6 +64,7 @@ import studioLayers from './studioLayers'; import studioPaint from './studioPaint'; import studioPhoto from './studioPhoto'; import studioStickers from './studioStickers'; +import studioShapes from './studioShapes'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -124,6 +125,7 @@ const locale = { ...studioPaint, ...studioStickers, ...studioPhoto, + ...studioShapes, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/uk/studioShapes.ts b/shared/src/i18n/uk/studioShapes.ts new file mode 100644 index 000000000..80c255bec --- /dev/null +++ b/shared/src/i18n/uk/studioShapes.ts @@ -0,0 +1,70 @@ +import type { TranslationStrings } from '../types'; + +/** Studio's second shelf of shapes, cut edges and drawn lines, and their two print checks. */ +const studioShapes: TranslationStrings = { + 'journey.studio.shapeGroup.arches': 'Арки', + 'journey.studio.shapeGroup.cutouts': 'Вирізки', + 'journey.studio.shapeGroup.stamps': 'Марки та значки', + 'journey.studio.shapeGroup.frames': 'Рамки', + 'journey.studio.shapeGroup.ornaments': 'Орнаменти', + 'journey.studio.drawnLines': 'Намальовані лінії', + 'journey.studio.dividers': 'Роздільники', + 'journey.studio.linePreset.straight': 'Пряма лінія', + 'journey.studio.linePreset.arrow': 'Стрілка', + 'journey.studio.linePreset.double': 'Подвійна стрілка', + 'journey.studio.linePreset.dashed': 'Штрихова лінія', + 'journey.studio.linePreset.dotted': 'Пунктирна лінія', + 'journey.studio.linePreset.curved': 'Вигнута стрілка', + 'journey.studio.linePreset.wave': 'Хвиляста лінія', + 'journey.studio.linePreset.zigzag': 'Зигзаг', + 'journey.studio.linePreset.hand': 'Стрілка від руки', + 'journey.studio.linePreset.route': 'Пунктирний маршрут', + 'journey.studio.linePreset.divider-diamond': 'Лінійка з ромбом', + 'journey.studio.linePreset.divider-sun': 'Лінійка з сонцем', + 'journey.studio.linePreset.divider-star': 'Лінійка із зіркою', + 'journey.studio.linePreset.divider-heart': 'Лінійка із серцем', + 'journey.studio.linePreset.divider-dot': 'Лінійка з крапкою', + 'journey.studio.kind.line': 'Лінія', + 'journey.studio.edgeStyle': 'Край', + 'journey.studio.edge.straight': 'Прямий', + 'journey.studio.edge.zigzag': 'Зигзаг', + 'journey.studio.edge.scallop': 'Фестони', + 'journey.studio.edge.perforated': 'Перфорація', + 'journey.studio.edge.wavy': 'Хвилястий', + 'journey.studio.edge.torn': 'Рваний', + 'journey.studio.edge.deckle': 'Кромка ручного паперу', + 'journey.studio.edgeSize': 'Глибина', + 'journey.studio.edgeUnavailable': + 'Ця фігура зберігає намальований контур. Край вирізається у широких фігур: прямокутників, кіл і арок.', + 'journey.studio.wobble': 'Від руки', + 'journey.studio.lineCourseTitle': 'Хід', + 'journey.studio.lineCourse.straight': 'Прямий', + 'journey.studio.lineCourse.smooth': 'Вигнутий', + 'journey.studio.lineBodyTitle': 'Тіло', + 'journey.studio.lineBody.none': 'Просте', + 'journey.studio.lineBody.wave': 'Хвиля', + 'journey.studio.lineBody.zigzag': 'Зигзаг', + 'journey.studio.linePointsHint': + 'Перетягніть точку на сторінці, щоб посунути її. Потягніть маленький маркер між двома точками, щоб додати точку, двічі клацніть точку, щоб видалити її.', + 'journey.studio.lineEnds': 'Кінці', + 'journey.studio.lineStart': 'Початок', + 'journey.studio.lineEnd': 'Кінець', + 'journey.studio.cap.none': 'Без кінця', + 'journey.studio.cap.arrow': 'Стрілка', + 'journey.studio.cap.open': 'Відкрита стрілка', + 'journey.studio.cap.dot': 'Крапка', + 'journey.studio.cap.bar': 'Риска', + 'journey.studio.swapEnds': 'Поміняти кінці', + 'journey.studio.ornamentTitle': 'Орнамент', + 'journey.studio.ornament.none': 'Немає', + 'journey.studio.ornament.diamond': 'Ромб', + 'journey.studio.ornament.dot': 'Крапка', + 'journey.studio.ornament.star': 'Зірка', + 'journey.studio.ornament.sun': 'Сонце', + 'journey.studio.ornament.heart': 'Серце', + 'journey.studio.addPoint': 'Потягніть, щоб додати точку', + 'journey.studio.removePoint': 'Подвійне клацання, щоб видалити', + 'assistant.card.book.finding.hairline': 'Лінія надто тонка для друку', + 'assistant.card.book.finding.edge_unused': 'Край, який ця фігура не приймає', +}; +export default studioShapes; diff --git a/shared/src/i18n/vi/index.ts b/shared/src/i18n/vi/index.ts index 60250d2d3..c0a37ffe0 100644 --- a/shared/src/i18n/vi/index.ts +++ b/shared/src/i18n/vi/index.ts @@ -64,6 +64,7 @@ import studioLayers from './studioLayers'; import studioPaint from './studioPaint'; import studioPhoto from './studioPhoto'; import studioStickers from './studioStickers'; +import studioShapes from './studioShapes'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -124,6 +125,7 @@ const locale = { ...studioPaint, ...studioStickers, ...studioPhoto, + ...studioShapes, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/vi/studioShapes.ts b/shared/src/i18n/vi/studioShapes.ts new file mode 100644 index 000000000..648c78ca4 --- /dev/null +++ b/shared/src/i18n/vi/studioShapes.ts @@ -0,0 +1,70 @@ +import type { TranslationStrings } from '../types'; + +/** Studio's second shelf of shapes, cut edges and drawn lines, and their two print checks. */ +const studioShapes: TranslationStrings = { + 'journey.studio.shapeGroup.arches': 'Mái vòm', + 'journey.studio.shapeGroup.cutouts': 'Hình cắt giấy', + 'journey.studio.shapeGroup.stamps': 'Tem và huy hiệu', + 'journey.studio.shapeGroup.frames': 'Khung', + 'journey.studio.shapeGroup.ornaments': 'Hoa văn', + 'journey.studio.drawnLines': 'Đường vẽ', + 'journey.studio.dividers': 'Đường phân cách', + 'journey.studio.linePreset.straight': 'Đường thẳng', + 'journey.studio.linePreset.arrow': 'Mũi tên', + 'journey.studio.linePreset.double': 'Mũi tên hai chiều', + 'journey.studio.linePreset.dashed': 'Đường gạch', + 'journey.studio.linePreset.dotted': 'Đường chấm', + 'journey.studio.linePreset.curved': 'Mũi tên cong', + 'journey.studio.linePreset.wave': 'Đường lượn sóng', + 'journey.studio.linePreset.zigzag': 'Đường zíc zắc', + 'journey.studio.linePreset.hand': 'Mũi tên vẽ tay', + 'journey.studio.linePreset.route': 'Lộ trình chấm', + 'journey.studio.linePreset.divider-diamond': 'Đường kẻ có hình thoi', + 'journey.studio.linePreset.divider-sun': 'Đường kẻ có mặt trời', + 'journey.studio.linePreset.divider-star': 'Đường kẻ có ngôi sao', + 'journey.studio.linePreset.divider-heart': 'Đường kẻ có trái tim', + 'journey.studio.linePreset.divider-dot': 'Đường kẻ có dấu chấm', + 'journey.studio.kind.line': 'Đường', + 'journey.studio.edgeStyle': 'Viền', + 'journey.studio.edge.straight': 'Thẳng', + 'journey.studio.edge.zigzag': 'Zíc zắc', + 'journey.studio.edge.scallop': 'Vỏ sò', + 'journey.studio.edge.perforated': 'Răng cưa tem', + 'journey.studio.edge.wavy': 'Lượn sóng', + 'journey.studio.edge.torn': 'Xé', + 'journey.studio.edge.deckle': 'Mép giấy thủ công', + 'journey.studio.edgeSize': 'Độ sâu', + 'journey.studio.edgeUnavailable': + 'Hình này giữ nguyên đường viền đã vẽ. Viền được cắt theo các hình rộng như hình chữ nhật, hình tròn và mái vòm.', + 'journey.studio.wobble': 'Vẽ tay', + 'journey.studio.lineCourseTitle': 'Hướng đi', + 'journey.studio.lineCourse.straight': 'Thẳng', + 'journey.studio.lineCourse.smooth': 'Cong', + 'journey.studio.lineBodyTitle': 'Thân', + 'journey.studio.lineBody.none': 'Trơn', + 'journey.studio.lineBody.wave': 'Sóng', + 'journey.studio.lineBody.zigzag': 'Zíc zắc', + 'journey.studio.linePointsHint': + 'Kéo một điểm trên trang để di chuyển nó. Kéo tay nắm nhỏ giữa hai điểm để thêm một điểm, nhấp đúp vào một điểm để xóa nó.', + 'journey.studio.lineEnds': 'Đầu mút', + 'journey.studio.lineStart': 'Đầu', + 'journey.studio.lineEnd': 'Cuối', + 'journey.studio.cap.none': 'Không có đầu', + 'journey.studio.cap.arrow': 'Mũi tên', + 'journey.studio.cap.open': 'Mũi tên mở', + 'journey.studio.cap.dot': 'Chấm', + 'journey.studio.cap.bar': 'Vạch', + 'journey.studio.swapEnds': 'Đổi hai đầu', + 'journey.studio.ornamentTitle': 'Hoa văn', + 'journey.studio.ornament.none': 'Không có', + 'journey.studio.ornament.diamond': 'Hình thoi', + 'journey.studio.ornament.dot': 'Chấm', + 'journey.studio.ornament.star': 'Ngôi sao', + 'journey.studio.ornament.sun': 'Mặt trời', + 'journey.studio.ornament.heart': 'Trái tim', + 'journey.studio.addPoint': 'Kéo để thêm một điểm', + 'journey.studio.removePoint': 'Nhấp đúp để xóa', + 'assistant.card.book.finding.hairline': 'Một đường quá mảnh để in', + 'assistant.card.book.finding.edge_unused': 'Một kiểu viền hình này không nhận', +}; +export default studioShapes; diff --git a/shared/src/i18n/zh-TW/index.ts b/shared/src/i18n/zh-TW/index.ts index f802df174..6370ef6f4 100644 --- a/shared/src/i18n/zh-TW/index.ts +++ b/shared/src/i18n/zh-TW/index.ts @@ -64,6 +64,7 @@ import studioLayers from './studioLayers'; import studioPaint from './studioPaint'; import studioPhoto from './studioPhoto'; import studioStickers from './studioStickers'; +import studioShapes from './studioShapes'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -123,6 +124,7 @@ const locale = { ...studioPaint, ...studioStickers, ...studioPhoto, + ...studioShapes, ...notif, ...oauth, ...system_notice, diff --git a/shared/src/i18n/zh-TW/studioShapes.ts b/shared/src/i18n/zh-TW/studioShapes.ts new file mode 100644 index 000000000..3cfae4b77 --- /dev/null +++ b/shared/src/i18n/zh-TW/studioShapes.ts @@ -0,0 +1,69 @@ +import type { TranslationStrings } from '../types'; + +/** Studio's second shelf of shapes, cut edges and drawn lines, and their two print checks. */ +const studioShapes: TranslationStrings = { + 'journey.studio.shapeGroup.arches': '拱形', + 'journey.studio.shapeGroup.cutouts': '剪紙', + 'journey.studio.shapeGroup.stamps': '郵票與徽章', + 'journey.studio.shapeGroup.frames': '相框', + 'journey.studio.shapeGroup.ornaments': '裝飾', + 'journey.studio.drawnLines': '手繪線條', + 'journey.studio.dividers': '分隔線', + 'journey.studio.linePreset.straight': '直線', + 'journey.studio.linePreset.arrow': '箭頭', + 'journey.studio.linePreset.double': '雙向箭頭', + 'journey.studio.linePreset.dashed': '虛線', + 'journey.studio.linePreset.dotted': '點線', + 'journey.studio.linePreset.curved': '彎曲箭頭', + 'journey.studio.linePreset.wave': '波浪線', + 'journey.studio.linePreset.zigzag': '鋸齒線', + 'journey.studio.linePreset.hand': '手繪箭頭', + 'journey.studio.linePreset.route': '點狀路線', + 'journey.studio.linePreset.divider-diamond': '帶菱形的分隔線', + 'journey.studio.linePreset.divider-sun': '帶太陽的分隔線', + 'journey.studio.linePreset.divider-star': '帶星形的分隔線', + 'journey.studio.linePreset.divider-heart': '帶愛心的分隔線', + 'journey.studio.linePreset.divider-dot': '帶圓點的分隔線', + 'journey.studio.kind.line': '線條', + 'journey.studio.edgeStyle': '邊緣', + 'journey.studio.edge.straight': '直邊', + 'journey.studio.edge.zigzag': '鋸齒', + 'journey.studio.edge.scallop': '扇貝邊', + 'journey.studio.edge.perforated': '齒孔', + 'journey.studio.edge.wavy': '波浪', + 'journey.studio.edge.torn': '撕邊', + 'journey.studio.edge.deckle': '毛邊紙', + 'journey.studio.edgeSize': '深度', + 'journey.studio.edgeUnavailable': '此形狀保留其繪製的輪廓。邊緣可沿矩形、圓形和拱形等寬闊形狀剪裁。', + 'journey.studio.wobble': '手繪感', + 'journey.studio.lineCourseTitle': '走向', + 'journey.studio.lineCourse.straight': '直線', + 'journey.studio.lineCourse.smooth': '曲線', + 'journey.studio.lineBodyTitle': '線身', + 'journey.studio.lineBody.none': '一般', + 'journey.studio.lineBody.wave': '波浪', + 'journey.studio.lineBody.zigzag': '鋸齒', + 'journey.studio.linePointsHint': + '在頁面上拖曳一個點即可移動它。拉動兩點之間的小控點可新增一個點,按兩下一個點可將其移除。', + 'journey.studio.lineEnds': '端點', + 'journey.studio.lineStart': '起點', + 'journey.studio.lineEnd': '終點', + 'journey.studio.cap.none': '無端點', + 'journey.studio.cap.arrow': '箭頭', + 'journey.studio.cap.open': '開口箭頭', + 'journey.studio.cap.dot': '圓點', + 'journey.studio.cap.bar': '橫槓', + 'journey.studio.swapEnds': '交換兩端', + 'journey.studio.ornamentTitle': '裝飾', + 'journey.studio.ornament.none': '無', + 'journey.studio.ornament.diamond': '菱形', + 'journey.studio.ornament.dot': '圓點', + 'journey.studio.ornament.star': '星形', + 'journey.studio.ornament.sun': '太陽', + 'journey.studio.ornament.heart': '愛心', + 'journey.studio.addPoint': '拉動以新增點', + 'journey.studio.removePoint': '按兩下移除', + 'assistant.card.book.finding.hairline': '線條太細,無法印刷', + 'assistant.card.book.finding.edge_unused': '此形狀不支援的邊緣', +}; +export default studioShapes; diff --git a/shared/src/i18n/zh/index.ts b/shared/src/i18n/zh/index.ts index 60250d2d3..c0a37ffe0 100644 --- a/shared/src/i18n/zh/index.ts +++ b/shared/src/i18n/zh/index.ts @@ -64,6 +64,7 @@ import studioLayers from './studioLayers'; import studioPaint from './studioPaint'; import studioPhoto from './studioPhoto'; import studioStickers from './studioStickers'; +import studioShapes from './studioShapes'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -124,6 +125,7 @@ const locale = { ...studioPaint, ...studioStickers, ...studioPhoto, + ...studioShapes, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/zh/studioShapes.ts b/shared/src/i18n/zh/studioShapes.ts new file mode 100644 index 000000000..579163be8 --- /dev/null +++ b/shared/src/i18n/zh/studioShapes.ts @@ -0,0 +1,69 @@ +import type { TranslationStrings } from '../types'; + +/** Studio's second shelf of shapes, cut edges and drawn lines, and their two print checks. */ +const studioShapes: TranslationStrings = { + 'journey.studio.shapeGroup.arches': '拱形', + 'journey.studio.shapeGroup.cutouts': '剪纸', + 'journey.studio.shapeGroup.stamps': '邮票与徽章', + 'journey.studio.shapeGroup.frames': '相框', + 'journey.studio.shapeGroup.ornaments': '装饰', + 'journey.studio.drawnLines': '手绘线条', + 'journey.studio.dividers': '分隔线', + 'journey.studio.linePreset.straight': '直线', + 'journey.studio.linePreset.arrow': '箭头', + 'journey.studio.linePreset.double': '双向箭头', + 'journey.studio.linePreset.dashed': '虚线', + 'journey.studio.linePreset.dotted': '点线', + 'journey.studio.linePreset.curved': '弯曲箭头', + 'journey.studio.linePreset.wave': '波浪线', + 'journey.studio.linePreset.zigzag': '锯齿线', + 'journey.studio.linePreset.hand': '手绘箭头', + 'journey.studio.linePreset.route': '点状路线', + 'journey.studio.linePreset.divider-diamond': '带菱形的分隔线', + 'journey.studio.linePreset.divider-sun': '带太阳的分隔线', + 'journey.studio.linePreset.divider-star': '带星形的分隔线', + 'journey.studio.linePreset.divider-heart': '带爱心的分隔线', + 'journey.studio.linePreset.divider-dot': '带圆点的分隔线', + 'journey.studio.kind.line': '线条', + 'journey.studio.edgeStyle': '边缘', + 'journey.studio.edge.straight': '直边', + 'journey.studio.edge.zigzag': '锯齿', + 'journey.studio.edge.scallop': '扇贝边', + 'journey.studio.edge.perforated': '齿孔', + 'journey.studio.edge.wavy': '波浪', + 'journey.studio.edge.torn': '撕边', + 'journey.studio.edge.deckle': '毛边纸', + 'journey.studio.edgeSize': '深度', + 'journey.studio.edgeUnavailable': '此形状保留其绘制的轮廓。边缘可沿矩形、圆形和拱形等宽阔形状剪切。', + 'journey.studio.wobble': '手绘感', + 'journey.studio.lineCourseTitle': '走向', + 'journey.studio.lineCourse.straight': '直线', + 'journey.studio.lineCourse.smooth': '曲线', + 'journey.studio.lineBodyTitle': '线身', + 'journey.studio.lineBody.none': '普通', + 'journey.studio.lineBody.wave': '波浪', + 'journey.studio.lineBody.zigzag': '锯齿', + 'journey.studio.linePointsHint': + '在页面上拖动一个点即可移动它。拉动两点之间的小手柄可添加一个点,双击一个点可将其删除。', + 'journey.studio.lineEnds': '端点', + 'journey.studio.lineStart': '起点', + 'journey.studio.lineEnd': '终点', + 'journey.studio.cap.none': '无端点', + 'journey.studio.cap.arrow': '箭头', + 'journey.studio.cap.open': '开口箭头', + 'journey.studio.cap.dot': '圆点', + 'journey.studio.cap.bar': '横杠', + 'journey.studio.swapEnds': '交换两端', + 'journey.studio.ornamentTitle': '装饰', + 'journey.studio.ornament.none': '无', + 'journey.studio.ornament.diamond': '菱形', + 'journey.studio.ornament.dot': '圆点', + 'journey.studio.ornament.star': '星形', + 'journey.studio.ornament.sun': '太阳', + 'journey.studio.ornament.heart': '爱心', + 'journey.studio.addPoint': '拉动以添加点', + 'journey.studio.removePoint': '双击删除', + 'assistant.card.book.finding.hairline': '线条太细,无法印刷', + 'assistant.card.book.finding.edge_unused': '此形状不支持的边缘', +}; +export default studioShapes; diff --git a/shared/src/index.ts b/shared/src/index.ts index 8ee1ab8ab..6af0168e6 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -128,6 +128,11 @@ export * from './journey/journey-map.schema'; export * from './journey/trip-journal-link.schema'; export * from './book/book.schema'; export * from './book/book-fonts'; +export * from './book/book-shapes-more'; +export * from './book/book-line.schema'; +export * from './book/book-line'; +export * from './book/book-line-ops.schema'; +export { bookNewLine, bookLineStyled } from './book/book-ops-line'; export * from './book/book-normalize'; export * from './book/book-schema-rev'; export * from './book/journey-stats.schema';