diff --git a/editor/src/messages/frontend/frontend_message.rs b/editor/src/messages/frontend/frontend_message.rs index 2160834136..4a5654fd4e 100644 --- a/editor/src/messages/frontend/frontend_message.rs +++ b/editor/src/messages/frontend/frontend_message.rs @@ -248,6 +248,7 @@ pub enum FrontendMessage { spacing: f64, interval: f64, visible: bool, + tilt: f64, }, UpdateDocumentScrollbars { position: (f64, f64), diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 76fbfa4828..c24d3684d5 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -807,6 +807,7 @@ impl MessageHandler> for DocumentMes spacing: ruler_spacing, interval: ruler_interval, visible: self.rulers_visible, + tilt: if self.graph_view_overlay_open { 0. } else { current_ptz.tilt() }, }); } DocumentMessage::RenderScrollbars => { diff --git a/frontend/src/components/panels/Document.svelte b/frontend/src/components/panels/Document.svelte index d8453997f8..23e90690c2 100644 --- a/frontend/src/components/panels/Document.svelte +++ b/frontend/src/components/panels/Document.svelte @@ -60,6 +60,7 @@ let rulerSpacing = 100; let rulerInterval = 100; let rulersVisible = true; + let rulerTilt = 0; // Rendered SVG viewport data let artworkSvg = ""; @@ -296,11 +297,12 @@ scrollbarMultiplier = multiplier; } - export function updateDocumentRulers(origin: XY, spacing: number, interval: number, visible: boolean) { + export function updateDocumentRulers(origin: XY, spacing: number, interval: number, visible: boolean, tilt: number) { rulerOrigin = origin; rulerSpacing = spacing; rulerInterval = interval; rulersVisible = visible; + rulerTilt = tilt; } // Update mouse cursor icon @@ -479,8 +481,8 @@ editor.subscriptions.subscribeJsMessage(UpdateDocumentRulers, async (data) => { await tick(); - const { origin, spacing, interval, visible } = data; - updateDocumentRulers(origin, spacing, interval, visible); + const { origin, spacing, interval, visible, tilt } = data; + updateDocumentRulers(origin, spacing, interval, visible, tilt); }); // Update mouse cursor icon @@ -572,13 +574,29 @@ {#if rulersVisible} - + {/if} {#if rulersVisible} - + {/if} diff --git a/frontend/src/components/widgets/inputs/RulerInput.svelte b/frontend/src/components/widgets/inputs/RulerInput.svelte index 1c907bcbd3..7a6488a926 100644 --- a/frontend/src/components/widgets/inputs/RulerInput.svelte +++ b/frontend/src/components/widgets/inputs/RulerInput.svelte @@ -11,81 +11,136 @@ const MICRO_MARK_THICKNESS = 3; export let direction: RulerDirection = "Vertical"; - export let origin: number; + export let originX: number; + export let originY: number; export let numberInterval: number; export let majorMarkSpacing: number; export let minorDivisions = 5; export let microDivisions = 2; + export let tilt: number = 0; let rulerInput: HTMLDivElement | undefined; let rulerLength = 0; let svgBounds = { width: "0px", height: "0px" }; - $: svgPath = computeSvgPath(direction, origin, majorMarkSpacing, minorDivisions, microDivisions, rulerLength); - $: svgTexts = computeSvgTexts(direction, origin, majorMarkSpacing, numberInterval, rulerLength); - - function computeSvgPath(direction: RulerDirection, origin: number, majorMarkSpacing: number, minorDivisions: number, microDivisions: number, rulerLength: number): string { - const isVertical = direction === "Vertical"; - const lineDirection = isVertical ? "H" : "V"; - - const offsetStart = mod(origin, majorMarkSpacing); - const shiftedOffsetStart = offsetStart - majorMarkSpacing; + type Axis = { + sign: number; + vec: [number, number]; + }; + + $: axes = computeAxes(tilt); + $: isHorizontal = direction === "Horizontal"; + $: trackedAxis = isHorizontal ? axes.horiz : axes.vert; + $: otherAxis = isHorizontal ? axes.vert : axes.horiz; + $: stretchFactor = 1 / (isHorizontal ? trackedAxis.vec[0] : trackedAxis.vec[1]); + $: stretchedSpacing = majorMarkSpacing * stretchFactor; + $: effectiveOrigin = computeEffectiveOrigin(direction, originX, originY, otherAxis); + $: svgPath = computeSvgPath(direction, effectiveOrigin, stretchedSpacing, minorDivisions, microDivisions, rulerLength, otherAxis); + $: svgTexts = computeSvgTexts(direction, effectiveOrigin, stretchedSpacing, numberInterval, rulerLength, trackedAxis, otherAxis); + + function computeAxes(tilt: number): { horiz: Axis; vert: Axis } { + const HALF_PI = Math.PI / 2; + const normTilt = ((tilt % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI); + const octant = Math.floor((normTilt + Math.PI / 4) / HALF_PI) % 4; + + const [c, s] = [Math.cos(tilt), Math.sin(tilt)]; + const posX: Axis = { sign: 1, vec: [c, s] }; + const posY: Axis = { sign: 1, vec: [-s, c] }; + const negX: Axis = { sign: -1, vec: [-c, -s] }; + const negY: Axis = { sign: -1, vec: [s, -c] }; + + if (octant === 0) return { horiz: posX, vert: posY }; + if (octant === 1) return { horiz: negY, vert: posX }; + if (octant === 2) return { horiz: negX, vert: negY }; + return { horiz: posY, vert: negX }; + } - const divisions = majorMarkSpacing / minorDivisions / microDivisions; - const majorMarksFrequency = minorDivisions * microDivisions; + function computeEffectiveOrigin(direction: RulerDirection, ox: number, oy: number, otherAxis: Axis): number { + const [vx, vy] = otherAxis.vec; + return direction === "Horizontal" ? ox - oy * (vx / vy) : oy - ox * (vy / vx); + } - let dPathAttribute = ""; + function computeSvgPath( + direction: RulerDirection, + effectiveOrigin: number, + stretchedSpacing: number, + minorDivisions: number, + microDivisions: number, + rulerLength: number, + otherAxis: Axis, + ): string { + const adaptive = stretchFactor > 1.3 ? { minor: minorDivisions, micro: 1 } : { minor: minorDivisions, micro: microDivisions }; + const divisions = stretchedSpacing / adaptive.minor / adaptive.micro; + const majorMarksFrequency = adaptive.minor * adaptive.micro; + const shiftedOffsetStart = mod(effectiveOrigin, stretchedSpacing) - stretchedSpacing; + + const [vx, vy] = otherAxis.vec; + // Tick direction: project outward from viewport edge into the ruler strip + const flip = direction === "Horizontal" ? (vy > 0 ? -1 : 1) : vx > 0 ? -1 : 1; + const [dx, dy] = [vx * flip, vy * flip]; + const [sxBase, syBase] = direction === "Horizontal" ? [0, RULER_THICKNESS] : [RULER_THICKNESS, 0]; + + let path = ""; let i = 0; - for (let location = shiftedOffsetStart; location < rulerLength; location += divisions) { - let length; - if (i % majorMarksFrequency === 0) length = MAJOR_MARK_THICKNESS; - else if (i % microDivisions === 0) length = MINOR_MARK_THICKNESS; - else length = MICRO_MARK_THICKNESS; + for (let loc = shiftedOffsetStart; loc < rulerLength + RULER_THICKNESS; loc += divisions) { + const length = i % majorMarksFrequency === 0 ? MAJOR_MARK_THICKNESS : i % adaptive.micro === 0 ? MINOR_MARK_THICKNESS : MICRO_MARK_THICKNESS; i += 1; - const destination = Math.round(location) + 0.5; - const startPoint = isVertical ? `${RULER_THICKNESS - length},${destination}` : `${destination},${RULER_THICKNESS - length}`; - dPathAttribute += `M${startPoint}${lineDirection}${RULER_THICKNESS} `; + const pos = Math.round(loc) + 0.5; + const [sx, sy] = direction === "Horizontal" ? [pos, syBase] : [sxBase, pos]; + path += `M${sx},${sy}l${dx * length},${dy * length} `; } - return dPathAttribute; + return path; } - function computeSvgTexts(direction: RulerDirection, origin: number, majorMarkSpacing: number, numberInterval: number, rulerLength: number): { transform: string; text: string }[] { + function computeSvgTexts( + direction: RulerDirection, + effectiveOrigin: number, + stretchedSpacing: number, + numberInterval: number, + rulerLength: number, + trackedAxis: Axis, + otherAxis: Axis, + ): { transform: string; text: string }[] { const isVertical = direction === "Vertical"; - const offsetStart = mod(origin, majorMarkSpacing); - const shiftedOffsetStart = offsetStart - majorMarkSpacing; + // Compute the tick tip offset so labels align with the top of the slanted tick + const [vx, vy] = otherAxis.vec; + const flip = isVertical ? (vx > 0 ? -1 : 1) : vy > 0 ? -1 : 1; + const tipOffsetX = vx * flip * MAJOR_MARK_THICKNESS; + const tipOffsetY = vy * flip * MAJOR_MARK_THICKNESS; - const svgTextCoordinates = []; + const shiftedOffsetStart = mod(effectiveOrigin, stretchedSpacing) - stretchedSpacing; + const increments = Math.round((shiftedOffsetStart - effectiveOrigin) / stretchedSpacing); + let labelNumber = increments * numberInterval * trackedAxis.sign; - let labelNumber = (Math.ceil(-origin / majorMarkSpacing) - 1) * numberInterval; + const results: { transform: string; text: string }[] = []; - for (let location = shiftedOffsetStart; location < rulerLength; location += majorMarkSpacing) { - const destination = Math.round(location); - const x = isVertical ? 9 : destination + 2; - const y = isVertical ? destination + 1 : 9; + for (let loc = shiftedOffsetStart; loc < rulerLength; loc += stretchedSpacing) { + const destination = Math.round(loc); + const x = isVertical ? 9 : destination + 2 + tipOffsetX; + const y = isVertical ? destination + 1 + tipOffsetY : 9; let transform = `translate(${x} ${y})`; - if (isVertical) transform += " rotate(270)"; - - const text = numberInterval >= 1 ? `${labelNumber}` : labelNumber.toFixed(Math.abs(Math.log10(numberInterval))).replace(/\.0+$/, ""); + if (isVertical) transform += " rotate(-90)"; - svgTextCoordinates.push({ transform, text }); + const num = Math.abs(labelNumber) < 1e-9 ? 0 : labelNumber; + const text = numberInterval >= 1 ? `${num}` : num.toFixed(Math.abs(Math.log10(numberInterval))).replace(/\.0+$/, ""); - labelNumber += numberInterval; + results.push({ transform, text }); + labelNumber += numberInterval * trackedAxis.sign; } - return svgTextCoordinates; + return results; } export function resize() { if (!rulerInput) return; const isVertical = direction === "Vertical"; - const newLength = isVertical ? rulerInput.clientHeight : rulerInput.clientWidth; - const roundedUp = (Math.floor(newLength / majorMarkSpacing) + 1) * majorMarkSpacing; + const roundedUp = (Math.floor(newLength / stretchedSpacing) + 2) * stretchedSpacing; if (roundedUp !== rulerLength) { rulerLength = roundedUp; @@ -95,7 +150,6 @@ } } - // Modulo function that works for negative numbers, unlike the JS `%` operator function mod(n: number, m: number): number { const remainder = n % m; return Math.floor(remainder >= 0 ? remainder : remainder + m); diff --git a/frontend/src/messages.ts b/frontend/src/messages.ts index afa7903a54..012b4c8cd0 100644 --- a/frontend/src/messages.ts +++ b/frontend/src/messages.ts @@ -666,6 +666,8 @@ export class UpdateDocumentRulers extends JsMessage { readonly interval!: number; readonly visible!: boolean; + + readonly tilt!: number; } export class EyedropperPreviewImage {