quickshell and hyprland additions

This commit is contained in:
2026-03-15 13:56:00 +02:00
parent c9c27d1554
commit 1ad06b82a6
509 changed files with 68371 additions and 19 deletions

View File

@@ -0,0 +1,57 @@
import QtQuick
import Quickshell
import qs.config
Item {
id: root
property real value: 0.65 // 0.0 → 1.0
property real strokeWidth: 2
property color bgColor: Appearance.m3colors.m3secondaryContainer
property color fgColor: Appearance.m3colors.m3primary
property string icon: "battery_full"
property int iconSize: Metrics.iconSize(20)
property bool fillIcon: false
width: 22
height: 24
onValueChanged: canvas.requestPaint()
Canvas {
id: canvas
anchors.fill: parent
onPaint: {
const ctx = getContext("2d");
ctx.clearRect(0, 0, width, height);
const cx = width / 2;
const cy = height / 2;
const r = (width - root.strokeWidth) / 2;
const start = -Math.PI / 2;
const end = start + 2 * Math.PI * root.value;
ctx.lineWidth = root.strokeWidth;
ctx.lineCap = "round";
// background ring
ctx.strokeStyle = root.bgColor;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, 2 * Math.PI);
ctx.stroke();
// progress ring
ctx.strokeStyle = root.fgColor;
ctx.beginPath();
ctx.arc(cx, cy, r, start, end);
ctx.stroke();
}
}
// CENTER ICON
MaterialSymbol {
anchors.centerIn: parent
icon: root.icon
iconSize: root.iconSize
font.variableAxes: {
"FILL": root.fillIcon ? 1 : 0
}
}
}

View File

@@ -0,0 +1,38 @@
import qs.config
import QtQuick
import QtQuick.Layouts
Item {
id: contentCard
implicitWidth: parent ? parent.width : 600
implicitHeight: contentArea.implicitHeight + verticalPadding
default property alias content: contentArea.data
property alias color: bg.color
property alias radius: bg.radius
property int cardMargin: Metrics.margin("normal")
property int cardSpacing: Metrics.margin("small")
property int verticalPadding: Metrics.margin("verylarge")
property bool useAnims: true
Rectangle {
id: bg
anchors.fill: parent
radius: Metrics.radius("normal")
color: Appearance.colors.colLayer1
Behavior on color {
enabled: Config.runtime.appearance.animations.enabled
ColorAnimation {
duration: !contentCard.useAnims ? 0 : Metrics.chronoDuration("fast")
}
}
}
ColumnLayout {
id: contentArea
anchors.fill: parent
anchors.margins: cardMargin
spacing: cardSpacing
}
}

View File

@@ -0,0 +1,101 @@
import qs.config
import QtQuick
import QtQuick.Layouts
Item {
id: contentMenu
Layout.fillWidth: true
Layout.fillHeight: true
opacity: visible ? 1 : 0
scale: visible ? 1 : 0.95
Behavior on opacity {
enabled: Config.runtime.appearance.animations.enabled
NumberAnimation {
duration: Metrics.chronoDuration("normal")
easing.type: Appearance.animation.curves.standard[0] // using standard easing
}
}
Behavior on scale {
enabled: Config.runtime.appearance.animations.enabled
NumberAnimation {
duration: Metrics.chronoDuration("normal")
easing.type: Appearance.animation.curves.standard[0]
}
}
property string title: ""
property string description: ""
default property alias content: stackedSections.data
Item {
id: headerArea
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.topMargin: Metrics.margin("verylarge")
anchors.leftMargin: Metrics.margin("verylarge")
anchors.rightMargin: Metrics.margin("verylarge")
width: parent.width
ColumnLayout {
id: headerContent
anchors.left: parent.left
anchors.right: parent.right
spacing: Metrics.margin("small")
ColumnLayout {
StyledText {
text: contentMenu.title
font.pixelSize: Metrics.fontSize("huge")
font.bold: true
font.family: Metrics.fontFamily("title")
}
StyledText {
text: contentMenu.description
font.pixelSize: Metrics.fontSize("small")
}
}
Rectangle {
id: hr
Layout.alignment: Qt.AlignLeft | Qt.AlignRight
implicitHeight: 1
}
}
height: headerContent.implicitHeight
}
Flickable {
id: mainScroll
anchors.left: parent.left
anchors.right: parent.right
anchors.top: headerArea.bottom
anchors.bottom: parent.bottom
anchors.leftMargin: Metrics.margin("verylarge")
anchors.rightMargin: Metrics.margin("verylarge")
anchors.topMargin: Metrics.margin("normal")
clip: true
interactive: true
boundsBehavior: Flickable.StopAtBounds
flickableDirection: Flickable.VerticalFlick
contentHeight: mainContent.childrenRect.height + Appearance.margin.small
contentWidth: width
Item {
id: mainContent
width: mainScroll.width
height: mainContent.childrenRect.height
Column {
id: stackedSections
width: Math.min(mainScroll.width, 1000)
x: (mainContent.width - width) / 2
spacing: Appearance.margin.normal
}
}
}
}

View File

@@ -0,0 +1,51 @@
import qs.config
import QtQuick
import QtQuick.Layouts
Item {
id: baseCard
Layout.fillWidth: true
implicitHeight: wpBG.implicitHeight
default property alias content: contentArea.data
property alias color: wpBG.color
property int cardMargin: Metrics.margin(20)
property int cardSpacing: Metrics.spacing(10)
property int radius: Metrics.radius("large")
property int verticalPadding: Metrics.padding(40)
Rectangle {
id: wpBG
anchors.left: parent.left
anchors.right: parent.right
implicitHeight: contentArea.implicitHeight + baseCard.verticalPadding
Behavior on implicitHeight {
enabled: Config.runtime.appearance.animations.enabled
NumberAnimation {
duration: Metrics.chronoDuration("small")
easing.type: Easing.InOutExpo
}
}
color: Appearance.m3colors.m3surfaceContainerLow
Behavior on color {
enabled: Config.runtime.appearance.animations.enabled
ColorAnimation {
duration: Metrics.chronoDuration("small")
easing.type: Easing.InOutExpo
}
}
radius: baseCard.radius
}
RowLayout {
id: contentArea
anchors.top: wpBG.top
anchors.left: wpBG.left
anchors.right: wpBG.right
anchors.margins: baseCard.cardMargin
spacing: baseCard.cardSpacing
}
}

View File

@@ -0,0 +1,58 @@
import qs.config
import QtQuick
import QtQuick.Layouts
ContentRowCard {
id: infoCard
// --- Properties ---
property string icon: "info"
property color backgroundColor: Appearance.m3colors.darkMode ? Qt.lighter(Appearance.m3colors.m3error, 3.5) : Qt.lighter(Appearance.m3colors.m3error, 1)
property color contentColor: Appearance.m3colors.m3onPrimary
property string title: "Title"
property string description: "Description"
color: backgroundColor
cardSpacing: Metrics.spacing(12) // nice spacing between elements
RowLayout {
id: mainLayout
Layout.fillHeight: true
Layout.fillWidth: true
spacing: Metrics.spacing(16)
Layout.alignment: Qt.AlignVCenter
// --- Icon ---
MaterialSymbol {
id: infoIcon
icon: infoCard.icon
iconSize: Metrics.iconSize(26)
color: contentColor
Layout.alignment: Qt.AlignVCenter
}
// --- Text column ---
ColumnLayout {
spacing: Metrics.spacing(2)
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
StyledText {
text: infoCard.title
font.bold: true
color: contentColor
font.pixelSize: Metrics.fontSize(14)
Layout.fillWidth: true
}
StyledText {
text: infoCard.description
color: contentColor
font.pixelSize: Metrics.fontSize(12)
Layout.fillWidth: true
wrapMode: Text.WordWrap
}
}
}
}

View File

@@ -0,0 +1,26 @@
import QtQuick
import qs.config
Item {
id: root
property alias icon: mIcon.icon
property real size: Metrics.iconSize(28)
width: size
height: size
MaterialSymbol {
id: mIcon
anchors.centerIn: parent
icon: "progress_activity"
font.pixelSize: root.size
color: Appearance.m3colors.m3primary
renderType: Text.QtRendering
}
RotationAnimator on rotation {
target: mIcon
running: true
loops: Animation.Infinite
from: 0
to: 360
duration: Metrics.chronoDuration(1000)
}
}

View File

@@ -0,0 +1,15 @@
import QtQuick
import qs.config
StyledText {
property string icon: ""
property int fill: 0
property int iconSize: Metrics.iconSize("large")
font.family: Appearance.font.family.materialIcons
font.pixelSize: iconSize
text: icon
font.variableAxes: {
"FILL": fill
}
}

View File

@@ -0,0 +1,56 @@
import QtQuick
import Quickshell
import qs.config
MaterialSymbol {
id: root
// Expose mouse props
property alias enabled: ma.enabled
property alias hoverEnabled: ma.hoverEnabled
property alias pressed: ma.pressed
property string tooltipText: ""
// Renamed signals (no collisions possible)
signal buttonClicked()
signal buttonEntered()
signal buttonExited()
signal buttonPressAndHold()
signal buttonPressedChanged(bool pressed)
MouseArea {
id: ma
anchors.fill: parent
hoverEnabled: true
onClicked: root.buttonClicked()
onEntered: root.buttonEntered()
onExited: root.buttonExited()
onPressAndHold: root.buttonPressAndHold()
onPressedChanged: root.buttonPressedChanged(pressed)
}
HoverHandler {
id: hover
enabled: root.tooltipText !== ""
}
LazyLoader {
active: root.tooltipText !== ""
StyledPopout {
hoverTarget: hover
hoverDelay: Metrics.chronoDuration(500)
Component {
StyledText {
text: root.tooltipText
}
}
}
}
}

View File

@@ -0,0 +1,88 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import qs.config
RowLayout {
id: root
property string label: ""
property string description: ""
property string prefField: ""
property double step: 1.0
property double minimum: -2.14748e+09 // Largest num I could find and type ig
property double maximum: 2.14748e+09
// Floating-point value
property double value: readValue()
function readValue() {
if (!prefField)
return 0;
var parts = prefField.split('.');
var cur = Config.runtime;
for (var i = 0; i < parts.length; ++i) {
if (cur === undefined || cur === null)
return 0;
cur = cur[parts[i]];
}
var n = Number(cur);
return isNaN(n) ? 0 : n;
}
function writeValue(v) {
if (!prefField)
return;
var nv = Math.max(minimum, Math.min(maximum, v));
nv = Number(nv.toFixed(2)); // precision control (adjust if needed)
Config.updateKey(prefField, nv);
}
spacing: Metrics.spacing(8)
Layout.alignment: Qt.AlignVCenter
ColumnLayout {
spacing: Metrics.spacing(2)
StyledText {
text: root.label
font.pixelSize: Metrics.fontSize(14)
}
StyledText {
text: root.description
font.pixelSize: Metrics.fontSize(10)
}
}
Item { Layout.fillWidth: true }
RowLayout {
spacing: Metrics.spacing(6)
StyledButton {
text: "-"
implicitWidth: 36
onClicked: writeValue(readValue() - step)
}
StyledText {
text: value.toFixed(2)
font.pixelSize: Metrics.fontSize(14)
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
width: 72
elide: Text.ElideRight
}
StyledButton {
text: "+"
implicitWidth: 36
onClicked: writeValue(readValue() + step)
}
}
}

View File

@@ -0,0 +1,185 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import qs.config
import qs.modules.functions
Control {
id: root
property alias text: label.text
property string icon: ""
property int iconSize: Metrics.iconSize(20)
property alias radius: background.radius
property alias topLeftRadius: background.topLeftRadius
property alias topRightRadius: background.topRightRadius
property alias bottomLeftRadius: background.bottomLeftRadius
property alias bottomRightRadius: background.bottomRightRadius
property bool checkable: false
property bool checked: true
property bool secondary: false
property string tooltipText: ""
property bool usePrimary: secondary ? false : checked
property color base_bg: usePrimary ? Appearance.m3colors.m3primary : Appearance.m3colors.m3secondaryContainer
property color base_fg: usePrimary ? Appearance.m3colors.m3onPrimary : Appearance.m3colors.m3onSecondaryContainer
property color disabled_bg: ColorUtils.transparentize(base_bg, 0.4)
property color disabled_fg: ColorUtils.transparentize(base_fg, 0.4)
property color hover_bg: Qt.lighter(base_bg, 1.1)
property color pressed_bg: Qt.darker(base_bg, 1.2)
property color backgroundColor: !root.enabled ? disabled_bg : mouse_area.pressed ? pressed_bg : mouse_area.containsMouse ? hover_bg : base_bg
property color textColor: !root.enabled ? disabled_fg : base_fg
property bool beingHovered: mouse_area.containsMouse
signal clicked()
signal toggled(bool checked)
implicitWidth: (label.text === "" && icon !== "") ? implicitHeight : row.implicitWidth + implicitHeight
implicitHeight: 40
MouseArea {
id: mouse_area
anchors.fill: parent
hoverEnabled: root.enabled
cursorShape: root.enabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor
onClicked: {
if (!root.enabled)
return ;
if (root.checkable) {
root.checked = !root.checked;
root.toggled(root.checked);
}
root.clicked();
}
}
HoverHandler {
id: hover
enabled: root.tooltipText !== ""
}
LazyLoader {
active: root.tooltipText !== ""
StyledPopout {
hoverTarget: hover
hoverDelay: Metrics.chronoDuration(500)
Component {
StyledText {
text: root.tooltipText
}
}
}
}
contentItem: Item {
anchors.fill: parent
Row {
id: row
anchors.centerIn: parent
spacing: root.icon !== "" && label.text !== "" ? 5 : 0
MaterialSymbol {
visible: root.icon !== ""
icon: root.icon
font.pixelSize: root.iconSize
color: root.textColor
anchors.verticalCenter: parent.verticalCenter
Behavior on color {
ColorAnimation {
duration: Metrics.chronoDuration("small") / 2
easing.type: Appearance.animation.easing
}
}
}
StyledText {
id: label
color: root.textColor
anchors.verticalCenter: parent.verticalCenter
elide: Text.ElideRight
Behavior on color {
ColorAnimation {
duration: Metrics.chronoDuration("small") / 2
easing.type: Appearance.animation.easing
}
}
}
}
}
background: Rectangle {
id: background
radius: Metrics.radius("large")
color: root.backgroundColor
Behavior on color {
ColorAnimation {
duration: Metrics.chronoDuration("small") / 2
easing.type: Appearance.animation.easing
}
}
Behavior on radius {
NumberAnimation {
duration: Metrics.chronoDuration("small") / 2
easing.type: Appearance.animation.easing
}
}
Behavior on topLeftRadius {
NumberAnimation {
duration: Metrics.chronoDuration("small")
easing.type: Appearance.animation.easing
}
}
Behavior on topRightRadius {
NumberAnimation {
duration: Metrics.chronoDuration("small")
easing.type: Appearance.animation.easing
}
}
Behavior on bottomLeftRadius {
NumberAnimation {
duration: Metrics.chronoDuration("small")
easing.type: Appearance.animation.easing
}
}
Behavior on bottomRightRadius {
NumberAnimation {
duration: Metrics.chronoDuration("small")
easing.type: Appearance.animation.easing
}
}
}
}

View File

@@ -0,0 +1,196 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Effects
import qs.config
import qs.modules.functions
Item {
id: root
width: 200
height: 56
property string label: "Select option"
property var model: ["Option 1", "Option 2", "Option 3", "Option 4", "Option 5"]
property int currentIndex: -1
property string currentText: {
if (currentIndex < 0)
return ""
if (textRole && model && model.get)
return model.get(currentIndex)[textRole] ?? ""
return model[currentIndex] ?? ""
}
property bool enabled: true
property string textRole: ""
signal selectedIndexChanged(int index)
Rectangle {
id: container
anchors.fill: parent
color: "transparent"
border.color: dropdown.activeFocus ? Appearance.m3colors.m3primary : Appearance.m3colors.m3outline
border.width: dropdown.activeFocus ? 2 : 1
radius: Metrics.radius("unsharpen")
Behavior on border.color {
enabled: Config.runtime.appearance.animations.enabled
ColorAnimation { duration: Metrics.chronoDuration("small") ; easing.type: Easing.InOutCubic }
}
Behavior on border.width {
enabled: Config.runtime.appearance.animations.enabled
NumberAnimation { duration: Metrics.chronoDuration("small") ; easing.type: Easing.InOutCubic }
}
MouseArea {
id: mouseArea
anchors.fill: parent
enabled: root.enabled
hoverEnabled: true
onClicked: dropdown.popup.visible ? dropdown.popup.close() : dropdown.popup.open()
Rectangle {
anchors.fill: parent
radius: parent.parent.radius
color: Appearance.m3colors.m3primary
opacity: mouseArea.pressed ? 0.12 : mouseArea.containsMouse ? 0.08 : 0
Behavior on opacity {
enabled: Config.runtime.appearance.animations.enabled
NumberAnimation { duration: Metrics.chronoDuration("small") ; easing.type: Easing.InOutCubic }
}
}
}
RowLayout {
anchors.fill: parent
anchors.leftMargin: Metrics.margin(16)
anchors.rightMargin: Metrics.margin(12)
spacing: Metrics.spacing(12)
StyledText {
id: labelText
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
text: root.currentIndex >= 0 ? root.currentText : root.label
color: root.currentIndex >= 0
? Appearance.m3colors.m3onSurface
: ColorUtils.transparentize(Appearance.m3colors.m3onSurfaceVariant, 0.7)
font.pixelSize: Metrics.fontSize(16)
elide: Text.ElideRight
verticalAlignment: Text.AlignVCenter
}
MaterialSymbol {
id: dropdownIcon
Layout.alignment: Qt.AlignVCenter
icon: dropdown.popup.visible ? "arrow_drop_up" : "arrow_drop_down"
iconSize: Metrics.iconSize(20)
color: Appearance.m3colors.m3onSurfaceVariant
}
}
}
ComboBox {
id: dropdown
visible: false
model: root.model
currentIndex: root.currentIndex >= 0 ? root.currentIndex : -1
enabled: root.enabled
textRole: root.textRole
onCurrentIndexChanged: {
if (currentIndex >= 0) {
root.currentIndex = currentIndex
root.selectedIndexChanged(currentIndex)
}
}
popup: Popup {
y: root.height + 4
width: root.width
padding: 0
background: Rectangle {
color: Appearance.m3colors.m3surfaceContainer
radius: Metrics.radius(4)
border.color: Appearance.m3colors.m3outline
border.width: 1
layer.enabled: true
layer.effect: MultiEffect {
shadowEnabled: true
shadowColor: ColorUtils.transparentize(Appearance.m3colors.m3shadow, 0.25)
shadowBlur: 0.4
shadowVerticalOffset: 8
shadowHorizontalOffset: 0
}
}
contentItem: ListView {
id: listView
clip: true
implicitHeight: Math.min(contentHeight, 300)
model: dropdown.popup.visible ? dropdown.model : []
currentIndex: Math.max(0, dropdown.currentIndex)
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
delegate: ItemDelegate {
width: listView.width
height: 48
background: Rectangle {
color: {
if (itemMouse.pressed) return ColorUtils.transparentize(Appearance.m3colors.m3primaryContainer, 0.12)
if (itemMouse.containsMouse) return ColorUtils.transparentize(Appearance.m3colors.m3primaryContainer, 0.08)
if (index === root.currentIndex) return ColorUtils.transparentize(Appearance.m3colors.m3primaryContainer, 0.08)
return "transparent"
}
Behavior on color {
ColorAnimation { duration: Metrics.chronoDuration("small") ; easing.type: Easing.InOutCubic }
}
}
contentItem: StyledText {
text: modelData
color: index === root.currentIndex ? Appearance.m3colors.m3primary : Appearance.m3colors.m3onSurface
font.pixelSize: Metrics.fontSize(16)
verticalAlignment: Text.AlignVCenter
leftPadding: Metrics.fontSize(16)
}
MouseArea {
id: itemMouse
anchors.fill: parent
hoverEnabled: true
onClicked: {
dropdown.currentIndex = index
dropdown.popup.close()
}
}
}
}
enter: Transition {
enabled: Config.runtime.appearance.animations.enabled
NumberAnimation {property: "opacity"; from: 0.0; to: 1.0; duration: Metrics.chronoDuration("small") ; easing.type: Easing.InOutCubic }
NumberAnimation {property: "scale"; from: 0.9; to: 1.0; duration: Metrics.chronoDuration("small") ; easing.type: Easing.InOutCubic }
}
exit: Transition {
enabled: Config.runtime.appearance.animations.enabled
NumberAnimation {property: "opacity"; from: 1.0; to: 0.0; duration: Metrics.chronoDuration(Appearance.animation.fast * 0.67); easing.type: Easing.InOutCubic }
}
}
}
focus: true
Keys.onPressed: (event) => {
if (event.key === Qt.Key_Space || event.key === Qt.Key_Return) {
dropdown.popup.visible ? dropdown.popup.close() : dropdown.popup.open()
event.accepted = true
}
}
}

View File

@@ -0,0 +1,13 @@
import QtQuick
import qs.services
import qs.config
Image {
asynchronous: true
retainWhileLoading: true
visible: opacity > 0
opacity: (status === Image.Ready) ? 1 : 0
Behavior on opacity {
animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this)
}
}

View File

@@ -0,0 +1,327 @@
import qs.config
import QtQuick
import QtQuick.Effects
import QtQuick.Layouts
import Quickshell
import Quickshell.Widgets
import Quickshell.Wayland
LazyLoader {
id: root
property string displayName: screen?.name ?? ""
property PanelWindow instance: null
property HoverHandler hoverTarget
property real margin: Metrics.margin(10)
default property list<Component> content
property bool startAnim: false
property bool isVisible: false
property bool keepAlive: false
property bool interactable: false
property bool hasHitbox: true
property bool hCenterOnItem: false
property bool followMouse: false
property list<StyledPopout> childPopouts: []
property bool requiresHover: true
property bool _manualControl: false
property int hoverDelay: Metrics.chronoDuration(250)
property bool targetHovered: hoverTarget && hoverTarget.hovered
property bool containerHovered: interactable && root.item && root.item.containerHovered
property bool selfHovered: targetHovered || containerHovered
property bool childrenHovered: {
for (let i = 0; i < childPopouts.length; i++) {
if (childPopouts[i].selfHovered)
return true;
}
return false;
}
property bool hoverActive: selfHovered || childrenHovered
property Timer showDelayTimer: Timer {
interval: root.hoverDelay
repeat: false
onTriggered: {
root.keepAlive = true;
root.isVisible = true;
root.startAnim = true;
}
}
property Timer hangTimer: Timer {
interval: Metrics.chronoDuration(200)
repeat: false
onTriggered: {
root.startAnim = false;
cleanupTimer.restart();
}
}
property Timer cleanupTimer: Timer {
interval: Metrics.chronoDuration("small")
repeat: false
onTriggered: {
root.isVisible = false;
root.keepAlive = false;
root._manualControl = false;
root.instance = null;
}
}
onHoverActiveChanged: {
if (_manualControl)
return;
if (!requiresHover)
return;
if (hoverActive) {
hangTimer.stop();
cleanupTimer.stop();
if (hoverDelay > 0) {
showDelayTimer.restart();
} else {
root.keepAlive = true;
root.isVisible = true;
root.startAnim = true;
}
} else {
showDelayTimer.stop();
hangTimer.restart();
}
}
function show() {
hangTimer.stop();
cleanupTimer.stop();
showDelayTimer.stop();
_manualControl = true;
keepAlive = true;
isVisible = true;
startAnim = true;
}
function hide() {
_manualControl = true;
showDelayTimer.stop();
startAnim = false;
hangTimer.stop();
cleanupTimer.restart();
}
active: keepAlive
component: PanelWindow {
id: popoutWindow
color: "transparent"
visible: root.isVisible
WlrLayershell.namespace: "whisker:popout"
WlrLayershell.layer: WlrLayer.Overlay
exclusionMode: ExclusionMode.Ignore
exclusiveZone: 0
anchors {
left: true
top: true
right: true
bottom: true
}
property bool exceedingHalf: false
property var parentPopoutWindow: null
property point mousePos: Qt.point(0, 0)
property bool containerHovered: root.interactable && containerHoverHandler.hovered
HoverHandler {
id: windowHover
onPointChanged: point => {
if (root.followMouse)
popoutWindow.mousePos = point.position;
}
}
mask: Region {
x: !root.hasHitbox ? 0 : !requiresHover ? 0 : container.x
y: !root.hasHitbox ? 0 : !requiresHover ? 0 : container.y
width: !root.hasHitbox ? 0 : !requiresHover ? popoutWindow.width : container.implicitWidth
height: !root.hasHitbox ? 0 : !requiresHover ? popoutWindow.height : container.implicitHeight
}
MouseArea {
id: mouseArea
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
hoverEnabled: false
onPressed: mouse => {
if (!containerHoverHandler.containsMouse && root.isVisible) {
root.hide();
}
}
}
Item {
id: container
implicitWidth: contentArea.implicitWidth + root.margin * 2
implicitHeight: contentArea.implicitHeight + root.margin * 2
x: {
let xValue;
if (root.followMouse)
xValue = mousePos.x + 10;
else {
let targetItem = hoverTarget?.parent;
if (!targetItem)
xValue = 0;
else {
let baseX = targetItem.mapToGlobal(Qt.point(0, 0)).x;
if (parentPopoutWindow)
baseX += parentPopoutWindow.x;
let targetWidth = targetItem.width;
let popupWidth = container.implicitWidth;
if (root.hCenterOnItem) {
let centeredX = baseX + (targetWidth - popupWidth) / 2;
if (centeredX + popupWidth > screen.width)
centeredX = screen.width - popupWidth - 10;
if (centeredX < 10)
centeredX = 10;
xValue = centeredX;
} else {
let xPos = baseX - ((ConfigResolver.bar(root.displayName).position === "top" || ConfigResolver.bar(root.displayName).position === "top") ? 20 : -40);
if (xPos + popupWidth > screen.width) {
exceedingHalf = true;
xValue = baseX - popupWidth;
} else {
exceedingHalf = false;
xValue = xPos;
}
}
}
}
return root.cleanupTimer.running ? xValue : Math.round(xValue);
}
y: {
let yValue;
if (root.followMouse)
yValue = mousePos.y + 10;
else {
let targetItem = hoverTarget?.parent;
if (!targetItem)
yValue = 0;
else {
let baseY = targetItem.mapToGlobal(Qt.point(0, 0)).y;
if (parentPopoutWindow)
baseY += parentPopoutWindow.y;
let targetHeight = targetItem.height;
let popupHeight = container.implicitHeight;
let yPos = baseY + ((ConfigResolver.bar(root.displayName).position === "top" || ConfigResolver.bar(root.displayName).position === "top") ? targetHeight : 0);
if (yPos > screen.height / 2)
yPos = baseY - popupHeight;
if (yPos + popupHeight > screen.height)
yPos = screen.height - popupHeight - 10;
if (yPos < 10)
yPos = 10;
yValue = yPos;
}
}
return root.cleanupTimer.running ? yValue : Math.round(yValue);
}
opacity: root.startAnim ? 1 : 0
scale: root.interactable ? 1 : root.startAnim ? 1 : 0.9
layer.enabled: true
layer.effect: MultiEffect {
shadowEnabled: true
shadowOpacity: 1
shadowColor: Appearance.m3colors.m3shadow
shadowBlur: 1
shadowScale: 1
}
Behavior on opacity {
NumberAnimation {
duration: Metrics.chronoDuration("small")
easing.type: Appearance.animation.easing
}
}
Behavior on scale {
enabled: !root.interactable
NumberAnimation {
duration: Metrics.chronoDuration("small")
easing.type: Appearance.animation.easing
}
}
Behavior on implicitWidth {
enabled: root.interactable
NumberAnimation {
duration: Metrics.chronoDuration("small")
easing.type: Appearance.animation.easing
}
}
Behavior on implicitHeight {
enabled: root.interactable
NumberAnimation {
duration: Metrics.chronoDuration("small")
easing.type: Appearance.animation.easing
}
}
ClippingRectangle {
id: popupBackground
anchors.fill: parent
color: Appearance.m3colors.m3surface
radius: Appearance.rounding.normal
ColumnLayout {
id: contentArea
anchors.fill: parent
anchors.margins: root.margin
}
}
HoverHandler {
id: containerHoverHandler
enabled: root.interactable
}
}
Component.onCompleted: {
root.instance = popoutWindow;
for (let i = 0; i < root.content.length; i++) {
const comp = root.content[i];
if (comp && comp.createObject) {
comp.createObject(contentArea);
} else {
console.warn("StyledPopout: invalid content:", comp);
}
}
let parentPopout = root.parent;
while (parentPopout && !parentPopout.childPopouts)
parentPopout = parentPopout.parent;
if (parentPopout) {
parentPopout.childPopouts.push(root);
if (parentPopout.item)
popoutWindow.parentPopoutWindow = parentPopout.item;
}
}
}
}

View File

@@ -0,0 +1,15 @@
import qs.config
import QtQuick
Rectangle {
id: root
Behavior on color {
enabled: Config.runtime.appearance.animations.enabled
ColorAnimation {
duration: Metrics.chronoDuration(600)
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.animation.curves.standard
}
}
}

View File

@@ -0,0 +1,118 @@
import qs.config
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
Slider {
id: root
property real trackHeightDiff: 15
property real handleGap: Metrics.spacing(4)
property real trackDotSize: Metrics.iconSize(4)
property real trackNearHandleRadius: Appearance.rounding.unsharpen
property bool useAnim: true
property int iconSize: Appearance.font.size.large
property string icon: ""
Layout.fillWidth: true
implicitWidth: 200
implicitHeight: 40
from: 0
to: 100
value: 0
stepSize: 0
snapMode: stepSize > 0 ? Slider.SnapAlways : Slider.NoSnap
MouseArea {
anchors.fill: parent
onPressed: (mouse) => mouse.accepted = false
cursorShape: root.pressed ? Qt.ClosedHandCursor : Qt.PointingHandCursor
}
MaterialSymbol {
id: icon
icon: root.icon
iconSize: root.iconSize
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.rightMargin: Metrics.margin(16)
}
background: Item {
anchors.verticalCenter: parent.verticalCenter
width: parent.width
height: parent.height
// Filled Left Segment
Rectangle {
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
width: root.handleGap + (root.visualPosition * (root.width - root.handleGap * 2))
- ((root.pressed ? 1.5 : 3) / 2 + root.handleGap)
height: root.height - root.trackHeightDiff
color: Appearance.colors.colPrimary
radius: Metrics.radius("small")
topRightRadius: root.trackNearHandleRadius
bottomRightRadius: root.trackNearHandleRadius
Behavior on width {
enabled: Config.runtime.appearance.animations.enabled
NumberAnimation {
duration: !root.useAnim ? 0 : Metrics.chronoDuration("small")
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.animation.curves.expressiveEffects
}
}
}
// Remaining Right Segment
Rectangle {
anchors.verticalCenter: parent.verticalCenter
anchors.right: parent.right
width: root.handleGap + ((1 - root.visualPosition) * (root.width - root.handleGap * 2))
- ((root.pressed ? 1.5 : 3) / 2 + root.handleGap)
height: root.height - root.trackHeightDiff
color: Appearance.colors.colSecondaryContainer
radius: Metrics.radius("small")
topLeftRadius: root.trackNearHandleRadius
bottomLeftRadius: root.trackNearHandleRadius
Behavior on width {
enabled: Config.runtime.appearance.animations.enabled
NumberAnimation {
duration: !root.useAnim ? 0 : Metrics.chronoDuration("small")
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.animation.curves.expressiveEffects
}
}
}
}
handle: Rectangle {
width: 5
height: root.height
radius: (width / 2) * Config.runtime.appearance.rounding.factor
x: root.handleGap + (root.visualPosition * (root.width - root.handleGap * 2)) - width / 2
anchors.verticalCenter: parent.verticalCenter
color: Appearance.colors.colPrimary
Behavior on x {
enabled: Config.runtime.appearance.animations.enabled
NumberAnimation {
duration: !root.useAnim ? 0 : Appearance.animation.elementMoveFast.duration
easing.type: Appearance.animation.elementMoveFast.type
easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve
}
}
}
}

View File

@@ -0,0 +1,138 @@
import qs.config
import QtQuick
import QtQuick.Controls
Item {
id: root
width: 60
height: 34
property bool checked: false
signal toggled(bool checked)
// Colors
property color trackOn: Appearance.colors.colPrimary
property color trackOff: Appearance.colors.colLayer2
property color outline: Appearance.colors.colOutline
property color thumbOn: Appearance.colors.colOnPrimary
property color thumbOff: Appearance.colors.colOnLayer2
property color iconOn: Appearance.colors.colPrimary
property color iconOff: Appearance.colors.colOnPrimary
// Dimensions
property int trackRadius: (height / 2) * Config.runtime.appearance.rounding.factor
property int thumbSize: height - (checked ? 10 : 14)
Behavior on thumbSize {
enabled: Config.runtime.appearance.animations.enabled
NumberAnimation {
duration: Metrics.chronoDuration("normal")
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.animation.curves.expressiveEffects
}
}
// TRACK
Rectangle {
id: track
anchors.fill: parent
radius: trackRadius
color: root.checked ? trackOn : trackOff
border.width: root.checked ? 0 : 2
border.color: outline
Behavior on color {
enabled: Config.runtime.appearance.animations.enabled
ColorAnimation {
duration: Metrics.chronoDuration("normal")
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.animation.curves.expressiveEffects
}
}
}
// THUMB
Rectangle {
id: thumb
width: thumbSize
height: thumbSize
radius: (thumbSize / 2) * Config.runtime.appearance.rounding.factor
anchors.verticalCenter: parent.verticalCenter
x: root.checked ? parent.width - width - 6 : 6
color: root.checked ? thumbOn : thumbOff
Behavior on x {
enabled: Config.runtime.appearance.animations.enabled
NumberAnimation {
duration: Metrics.chronoDuration("small")
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.animation.curves.expressiveEffects
}
}
Behavior on color {
enabled: Config.runtime.appearance.animations.enabled
ColorAnimation {
duration: Metrics.chronoDuration("small")
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.animation.curves.expressiveEffects
}
}
// ✓ CHECK ICON
MaterialSymbol {
anchors.centerIn: parent
icon: "check"
iconSize: parent.width * 0.7
color: iconOn
opacity: root.checked ? 1 : 0
scale: root.checked ? 1 : 0.6
Behavior on opacity { NumberAnimation { duration: 120 } }
Behavior on scale {
NumberAnimation {
duration: 160
easing.type: Easing.OutBack
}
}
}
// ✕ CROSS ICON (more visible)
MaterialSymbol {
anchors.centerIn: parent
icon: "close"
iconSize: parent.width * 0.72
color: iconOff
opacity: root.checked ? 0 : 1
scale: root.checked ? 0.6 : 1
Behavior on opacity { NumberAnimation { duration: 120 } }
Behavior on scale {
NumberAnimation {
duration: 160
easing.type: Easing.OutBack
}
}
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
root.checked = !root.checked
root.toggled(root.checked)
}
}
}

View File

@@ -0,0 +1,37 @@
import qs.config
import Quickshell
import QtQuick
import QtQuick.Layouts
RowLayout {
id: main
property string title: "Title"
property string description: "Description"
property string prefField: ''
ColumnLayout {
StyledText { text: main.title; font.pixelSize: Metrics.fontSize(16); }
StyledText { text: main.description; font.pixelSize: Metrics.fontSize(12); }
}
Item { Layout.fillWidth: true }
StyledSwitch {
// Safely resolve nested key (e.g. "background.showClock" or "bar.modules.some.setting")
checked: {
if (!main.prefField) return false;
var parts = main.prefField.split('.');
var cur = Config.runtime;
for (var i = 0; i < parts.length; ++i) {
if (cur === undefined || cur === null) return false;
cur = cur[parts[i]];
}
// If the config value is undefined, default to false
return cur === undefined || cur === null ? false : cur;
}
onToggled: {
// Persist change (updateKey will create missing objects)
Config.updateKey(main.prefField, checked);
}
}
}

View File

@@ -0,0 +1,54 @@
pragma ComponentBehavior: Bound
import qs.config
import QtQuick
Text {
id: root
// from github.com/yannpelletier/twinshell with modifications
property bool animate: true
property string animateProp: "scale"
property real animateFrom: 0
property real animateTo: 1
property int animateDuration: Metrics.chronoDuration("small")
renderType: Text.NativeRendering
textFormat: Text.PlainText
color: Appearance.syntaxHighlightingTheme
font.family: Metrics.fontFamily("main")
font.pixelSize: Metrics.fontSize("normal")
Behavior on color {
enabled: Config.runtime.appearance.animations.enabled
ColorAnimation {
duration: Metrics.chronoDuration("small")
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.animation.curves.standard
}
}
Behavior on text {
enabled: Config.runtime.appearance.animations.enabled && root.animate
SequentialAnimation {
Anim {
to: root.animateFrom
easing.bezierCurve: Appearance.animation.curves.standardAccel
}
PropertyAction {}
Anim {
to: root.animateTo
easing.bezierCurve: Appearance.animation.curves.standardDecel
}
}
}
component Anim: NumberAnimation {
target: root
property: root.animateProp
duration: root.animateDuration / 2
easing.type: Easing.BezierSpline
}
}

View File

@@ -0,0 +1,195 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Effects
import qs.config
import qs.modules.functions
TextField {
id: control
property string icon: ""
property color iconColor: Appearance.m3colors.m3onSurfaceVariant
property string placeholder: ""
property real iconSize: Metrics.iconSize(24)
property alias radius: bg.radius
property bool outline: true
property alias topLeftRadius: bg.topLeftRadius
property alias topRightRadius: bg.topRightRadius
property alias bottomLeftRadius: bg.bottomLeftRadius
property alias bottomRightRadius: bg.bottomRightRadius
property color backgroundColor: filled ? Appearance.m3colors.m3surfaceContainerHigh : "transparent"
property int fieldPadding: Metrics.padding(20)
property int iconSpacing: Metrics.spacing(14)
property int iconMargin: Metrics.margin(20)
property bool filled: true
property bool highlight: true
width: parent ? parent.width - 40 : 300
placeholderText: placeholder
leftPadding: icon !== "" ? iconSize + iconSpacing + iconMargin : fieldPadding
padding: fieldPadding
verticalAlignment: TextInput.AlignVCenter
color: Appearance.m3colors.m3onSurface
placeholderTextColor: Appearance.m3colors.m3onSurfaceVariant
font.family: "Outfit"
font.pixelSize: Metrics.fontSize(14)
cursorVisible: control.focus
MaterialSymbol {
icon: control.icon
anchors.left: parent.left
anchors.leftMargin: icon !== "" ? iconMargin : 0
anchors.verticalCenter: parent.verticalCenter
font.pixelSize: control.iconSize
color: control.iconColor
visible: control.icon !== ""
Behavior on color {
ColorAnimation {
duration: Metrics.chronoDuration("small")
easing.type: Appearance.animation.easing
}
}
}
cursorDelegate: Rectangle {
width: 2
color: Appearance.m3colors.m3primary
visible: control.focus
SequentialAnimation on opacity {
loops: Animation.Infinite
running: control.focus && Config.runtime.appearance.animations.enabled
NumberAnimation {
from: 1
to: 0
duration: Metrics.chronoDuration("lrage") * 2
}
NumberAnimation {
from: 0
to: 1
duration: Metrics.chronoDuration("lrage") * 2
}
}
}
background: Item {
Rectangle {
id: bg
anchors.fill: parent
radius: Metrics.radius("unsharpenmore")
color: control.backgroundColor
Rectangle {
anchors.fill: parent
radius: parent.radius
color: {
if (control.activeFocus && control.highlight)
return ColorUtils.transparentize(Appearance.m3colors.m3primary, 0.8);
if (control.hovered && control.highlight)
return ColorUtils.transparentize(Appearance.m3colors.m3onSurface, 0.9);
return "transparent";
}
Behavior on color {
enabled: Config.runtime.appearance.animations.enabled
ColorAnimation {
duration: Metrics.chronoDuration("small")
easing.type: Appearance.animation.easing
}
}
}
}
Rectangle {
id: indicator
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: control.activeFocus ? 2 : 1
color: {
if (control.activeFocus)
return Appearance.m3colors.m3primary;
if (control.hovered)
return Appearance.m3colors.m3onSurface;
return Appearance.m3colors.m3onSurface;
}
visible: filled
Behavior on height {
enabled: Config.runtime.appearance.animations.enabled
NumberAnimation {
duration: Metrics.chronoDuration("small")
easing.type: Appearance.animation.easing
}
}
Behavior on color {
enabled: Config.runtime.appearance.animations.enabled
ColorAnimation {
duration: Metrics.chronoDuration("small")
easing.type: Appearance.animation.easing
}
}
}
Rectangle {
id: outline
anchors.fill: parent
radius: bg.radius
color: "transparent"
border.width: control.activeFocus ? 2 : 1
border.color: {
if (control.activeFocus)
return Appearance.m3colors.m3primary;
if (control.hovered)
return Appearance.m3colors.m3onSurface;
return Appearance.m3colors.m3outline;
}
visible: !filled && control.outline
Behavior on border.width {
enabled: Config.runtime.appearance.animations.enabled
NumberAnimation {
duration: Metrics.chronoDuration("small")
easing.type: Appearance.animation.easing
}
}
Behavior on border.color {
enabled: Config.runtime.appearance.animations.enabled
ColorAnimation {
duration: Metrics.chronoDuration("small")
easing.type: Appearance.animation.easing
}
}
}
}
}

View File

@@ -0,0 +1,22 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Effects
import qs.config
Item {
property var sourceItem: null
Loader {
active: Config.runtime.appearance.tintIcons
anchors.fill: parent
sourceComponent: MultiEffect {
source: sourceItem
saturation: -1.0
contrast: 0.10
brightness: -0.08
blur: 0.0
}
}
}

View File

@@ -0,0 +1,103 @@
import QtQuick
import "shapes/morph.js" as Morph
import qs.config
// From github.com/end-4/rounded-polygons-qmljs
Canvas {
id: root
property color color: "#685496"
property var roundedPolygon: null
property bool polygonIsNormalized: true
property real borderWidth: 0
property color borderColor: color
property bool debug: false
// Internals: size
property var bounds: roundedPolygon.calculateBounds()
implicitWidth: bounds[2] - bounds[0]
implicitHeight: bounds[3] - bounds[1]
// Internals: anim
property var prevRoundedPolygon: null
property double progress: 1
property var morph: new Morph.Morph(roundedPolygon, roundedPolygon)
property Animation animation: NumberAnimation {
duration: Metrics.chronoDuration(350)
easing.type: Easing.BezierSpline
easing.bezierCurve: [0.42, 1.67, 0.21, 0.90, 1, 1] // Material 3 Expressive fast spatial (https://m3.material.io/styles/motion/overview/specs)
}
onRoundedPolygonChanged: {
delete root.morph
root.morph = new Morph.Morph(root.prevRoundedPolygon ?? root.roundedPolygon, root.roundedPolygon)
morphBehavior.enabled = false;
root.progress = 0
morphBehavior.enabled = true;
root.progress = 1
root.prevRoundedPolygon = root.roundedPolygon
}
Behavior on progress {
id: morphBehavior
animation: root.animation
}
onProgressChanged: requestPaint()
onColorChanged: requestPaint()
onBorderWidthChanged: requestPaint()
onBorderColorChanged: requestPaint()
onDebugChanged: requestPaint()
onPaint: {
var ctx = getContext("2d")
ctx.fillStyle = root.color
ctx.clearRect(0, 0, width, height)
if (!root.morph) return
const cubics = root.morph.asCubics(root.progress)
if (cubics.length === 0) return
const size = Math.min(root.width, root.height)
ctx.save()
if (root.polygonIsNormalized) ctx.scale(size, size)
ctx.beginPath()
ctx.moveTo(cubics[0].anchor0X, cubics[0].anchor0Y)
for (const cubic of cubics) {
ctx.bezierCurveTo(
cubic.control0X, cubic.control0Y,
cubic.control1X, cubic.control1Y,
cubic.anchor1X, cubic.anchor1Y
)
}
ctx.closePath()
ctx.fill()
if (root.borderWidth > 0) {
ctx.strokeStyle = root.borderColor
ctx.lineWidth = root.borderWidth
ctx.stroke()
}
if (root.debug) {
const points = []
for (let i = 0; i < cubics.length; ++i) {
const c = cubics[i]
if (i === 0)
points.push({ x: c.anchor0X, y: c.anchor0Y })
points.push({ x: c.anchor1X, y: c.anchor1Y })
}
let radius = Metrics.radius(2)
ctx.fillStyle = "red"
for (const p of points) {
ctx.beginPath()
ctx.arc(p.x, p.y, radius, 0, Math.PI * 2)
ctx.fill()
}
}
ctx.restore()
}
}

View File

@@ -0,0 +1,177 @@
.pragma library
/**
* @param {number} x
* @param {number} y
* @returns {Offset}
*/
function createOffset(x, y) {
return new Offset(x, y);
}
class Offset {
/**
* @param {number} x
* @param {number} y
*/
constructor(x, y) {
this.x = x;
this.y = y;
}
/**
* @param {number} x
* @param {number} y
* @returns {Offset}
*/
copy(x = this.x, y = this.y) {
return new Offset(x, y);
}
/**
* @returns {number}
*/
getDistance() {
return Math.sqrt(this.x * this.x + this.y * this.y);
}
/**
* @returns {number}
*/
getDistanceSquared() {
return this.x * this.x + this.y * this.y;
}
/**
* @returns {boolean}
*/
isValid() {
return isFinite(this.x) && isFinite(this.y);
}
/**
* @returns {boolean}
*/
get isFinite() {
return isFinite(this.x) && isFinite(this.y);
}
/**
* @returns {boolean}
*/
get isSpecified() {
return !this.isUnspecified;
}
/**
* @returns {boolean}
*/
get isUnspecified() {
return Object.is(this.x, NaN) && Object.is(this.y, NaN);
}
/**
* @returns {Offset}
*/
negate() {
return new Offset(-this.x, -this.y);
}
/**
* @param {Offset} other
* @returns {Offset}
*/
minus(other) {
return new Offset(this.x - other.x, this.y - other.y);
}
/**
* @param {Offset} other
* @returns {Offset}
*/
plus(other) {
return new Offset(this.x + other.x, this.y + other.y);
}
/**
* @param {number} operand
* @returns {Offset}
*/
times(operand) {
return new Offset(this.x * operand, this.y * operand);
}
/**
* @param {number} operand
* @returns {Offset}
*/
div(operand) {
return new Offset(this.x / operand, this.y / operand);
}
/**
* @param {number} operand
* @returns {Offset}
*/
rem(operand) {
return new Offset(this.x % operand, this.y % operand);
}
/**
* @returns {string}
*/
toString() {
if (this.isSpecified) {
return `Offset(${this.x.toFixed(1)}, ${this.y.toFixed(1)})`;
} else {
return 'Offset.Unspecified';
}
}
/**
* @param {Offset} start
* @param {Offset} stop
* @param {number} fraction
* @returns {Offset}
*/
static lerp(start, stop, fraction) {
return new Offset(
start.x + (stop.x - start.x) * fraction,
start.y + (stop.y - start.y) * fraction
);
}
/**
* @param {function(): Offset} block
* @returns {Offset}
*/
takeOrElse(block) {
return this.isSpecified ? this : block();
}
/**
* @returns {number}
*/
angleDegrees() {
return Math.atan2(this.y, this.x) * 180 / Math.PI;
}
/**
* @param {number} angle
* @param {Offset} center
* @returns {Offset}
*/
rotateDegrees(angle, center = Offset.Zero) {
const a = angle * Math.PI / 180;
const off = this.minus(center);
const cosA = Math.cos(a);
const sinA = Math.sin(a);
const newX = off.x * cosA - off.y * sinA;
const newY = off.x * sinA + off.y * cosA;
return new Offset(newX, newY).plus(center);
}
}
Offset.Zero = new Offset(0, 0);
Offset.Infinite = new Offset(Infinity, Infinity);
Offset.Unspecified = new Offset(NaN, NaN);

View File

@@ -0,0 +1,198 @@
.pragma library
.import "../geometry/offset.js" as Offset
class Matrix {
constructor(values = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]) {
this.values = values;
}
get(row, column) {
return this.values[(row * 4) + column];
}
set(row, column, v) {
this.values[(row * 4) + column] = v;
}
/** Does the 3D transform on [point] and returns the `x` and `y` values in an [Offset]. */
map(point) {
if (this.values.length < 16) return point;
const v00 = this.get(0, 0);
const v01 = this.get(0, 1);
const v03 = this.get(0, 3);
const v10 = this.get(1, 0);
const v11 = this.get(1, 1);
const v13 = this.get(1, 3);
const v30 = this.get(3, 0);
const v31 = this.get(3, 1);
const v33 = this.get(3, 3);
const x = point.x;
const y = point.y;
const z = v03 * x + v13 * y + v33;
const inverseZ = 1 / z;
const pZ = isFinite(inverseZ) ? inverseZ : 0;
return new Offset.Offset(pZ * (v00 * x + v10 * y + v30), pZ * (v01 * x + v11 * y + v31));
}
/** Multiply this matrix by [m] and assign the result to this matrix. */
timesAssign(m) {
const v = this.values;
if (v.length < 16) return;
if (m.values.length < 16) return;
const v00 = this.dot(0, m, 0);
const v01 = this.dot(0, m, 1);
const v02 = this.dot(0, m, 2);
const v03 = this.dot(0, m, 3);
const v10 = this.dot(1, m, 0);
const v11 = this.dot(1, m, 1);
const v12 = this.dot(1, m, 2);
const v13 = this.dot(1, m, 3);
const v20 = this.dot(2, m, 0);
const v21 = this.dot(2, m, 1);
const v22 = this.dot(2, m, 2);
const v23 = this.dot(2, m, 3);
const v30 = this.dot(3, m, 0);
const v31 = this.dot(3, m, 1);
const v32 = this.dot(3, m, 2);
const v33 = this.dot(3, m, 3);
v[0] = v00;
v[1] = v01;
v[2] = v02;
v[3] = v03;
v[4] = v10;
v[5] = v11;
v[6] = v12;
v[7] = v13;
v[8] = v20;
v[9] = v21;
v[10] = v22;
v[11] = v23;
v[12] = v30;
v[13] = v31;
v[14] = v32;
v[15] = v33;
}
dot(row, m, column) {
return this.get(row, 0) * m.get(0, column) +
this.get(row, 1) * m.get(1, column) +
this.get(row, 2) * m.get(2, column) +
this.get(row, 3) * m.get(3, column);
}
/** Resets the `this` to the identity matrix. */
reset() {
const v = this.values;
if (v.length < 16) return;
v[0] = 1;
v[1] = 0;
v[2] = 0;
v[3] = 0;
v[4] = 0;
v[5] = 1;
v[6] = 0;
v[7] = 0;
v[8] = 0;
v[9] = 0;
v[10] = 1;
v[11] = 0;
v[12] = 0;
v[13] = 0;
v[14] = 0;
v[15] = 1;
}
/** Applies a [degrees] rotation around Z to `this`. */
rotateZ(degrees) {
if (this.values.length < 16) return;
const r = degrees * (Math.PI / 180.0);
const s = Math.sin(r);
const c = Math.cos(r);
const a00 = this.get(0, 0);
const a10 = this.get(1, 0);
const v00 = c * a00 + s * a10;
const v10 = -s * a00 + c * a10;
const a01 = this.get(0, 1);
const a11 = this.get(1, 1);
const v01 = c * a01 + s * a11;
const v11 = -s * a01 + c * a11;
const a02 = this.get(0, 2);
const a12 = this.get(1, 2);
const v02 = c * a02 + s * a12;
const v12 = -s * a02 + c * a12;
const a03 = this.get(0, 3);
const a13 = this.get(1, 3);
const v03 = c * a03 + s * a13;
const v13 = -s * a03 + c * a13;
this.set(0, 0, v00);
this.set(0, 1, v01);
this.set(0, 2, v02);
this.set(0, 3, v03);
this.set(1, 0, v10);
this.set(1, 1, v11);
this.set(1, 2, v12);
this.set(1, 3, v13);
}
/** Scale this matrix by [x], [y], [z] */
scale(x = 1, y = 1, z = 1) {
if (this.values.length < 16) return;
this.set(0, 0, this.get(0, 0) * x);
this.set(0, 1, this.get(0, 1) * x);
this.set(0, 2, this.get(0, 2) * x);
this.set(0, 3, this.get(0, 3) * x);
this.set(1, 0, this.get(1, 0) * y);
this.set(1, 1, this.get(1, 1) * y);
this.set(1, 2, this.get(1, 2) * y);
this.set(1, 3, this.get(1, 3) * y);
this.set(2, 0, this.get(2, 0) * z);
this.set(2, 1, this.get(2, 1) * z);
this.set(2, 2, this.get(2, 2) * z);
this.set(2, 3, this.get(2, 3) * z);
}
/** Translate this matrix by [x], [y], [z] */
translate(x = 0, y = 0, z = 0) {
if (this.values.length < 16) return;
const t1 = this.get(0, 0) * x + this.get(1, 0) * y + this.get(2, 0) * z + this.get(3, 0);
const t2 = this.get(0, 1) * x + this.get(1, 1) * y + this.get(2, 1) * z + this.get(3, 1);
const t3 = this.get(0, 2) * x + this.get(1, 2) * y + this.get(2, 2) * z + this.get(3, 2);
const t4 = this.get(0, 3) * x + this.get(1, 3) * y + this.get(2, 3) * z + this.get(3, 3);
this.set(3, 0, t1);
this.set(3, 1, t2);
this.set(3, 2, t3);
this.set(3, 3, t4);
}
toString() {
return `${this.get(0, 0)} ${this.get(0, 1)} ${this.get(0, 2)} ${this.get(0, 3)}\n` +
`${this.get(1, 0)} ${this.get(1, 1)} ${this.get(1, 2)} ${this.get(1, 3)}\n` +
`${this.get(2, 0)} ${this.get(2, 1)} ${this.get(2, 2)} ${this.get(2, 3)}\n` +
`${this.get(3, 0)} ${this.get(3, 1)} ${this.get(3, 2)} ${this.get(3, 3)}`;
}
}
// Companion object constants
Matrix.ScaleX = 0;
Matrix.SkewY = 1;
Matrix.Perspective0 = 3;
Matrix.SkewX = 4;
Matrix.ScaleY = 5;
Matrix.Perspective1 = 7;
Matrix.ScaleZ = 10;
Matrix.TranslateX = 12;
Matrix.TranslateY = 13;
Matrix.TranslateZ = 14;
Matrix.Perspective2 = 15;

View File

@@ -0,0 +1,712 @@
.pragma library
.import "shapes/point.js" as Point
.import "shapes/rounded-polygon.js" as RoundedPolygon
.import "shapes/corner-rounding.js" as CornerRounding
.import "geometry/offset.js" as Offset
.import "graphics/matrix.js" as Matrix
var _circle = null
var _square = null
var _slanted = null
var _arch = null
var _fan = null
var _arrow = null
var _semiCircle = null
var _oval = null
var _pill = null
var _triangle = null
var _diamond = null
var _clamShell = null
var _pentagon = null
var _gem = null
var _verySunny = null
var _sunny = null
var _cookie4Sided = null
var _cookie6Sided = null
var _cookie7Sided = null
var _cookie9Sided = null
var _cookie12Sided = null
var _ghostish = null
var _clover4Leaf = null
var _clover8Leaf = null
var _burst = null
var _softBurst = null
var _boom = null
var _softBoom = null
var _flower = null
var _puffy = null
var _puffyDiamond = null
var _pixelCircle = null
var _pixelTriangle = null
var _bun = null
var _heart = null
var cornerRound15 = new CornerRounding.CornerRounding(0.15)
var cornerRound20 = new CornerRounding.CornerRounding(0.2)
var cornerRound30 = new CornerRounding.CornerRounding(0.3)
var cornerRound50 = new CornerRounding.CornerRounding(0.5)
var cornerRound100 = new CornerRounding.CornerRounding(1.0)
var rotateNeg30 = new Matrix.Matrix();
rotateNeg30.rotateZ(-30);
var rotateNeg45 = new Matrix.Matrix();
rotateNeg45.rotateZ(-45);
var rotateNeg90 = new Matrix.Matrix();
rotateNeg90.rotateZ(-90);
var rotateNeg135 = new Matrix.Matrix();
rotateNeg135.rotateZ(-135);
var rotate30 = new Matrix.Matrix();
rotate30.rotateZ(30);
var rotate45 = new Matrix.Matrix();
rotate45.rotateZ(45);
var rotate60 = new Matrix.Matrix();
rotate60.rotateZ(60);
var rotate90 = new Matrix.Matrix();
rotate90.rotateZ(90);
var rotate120 = new Matrix.Matrix();
rotate120.rotateZ(120);
var rotate135 = new Matrix.Matrix();
rotate135.rotateZ(135);
var rotate180 = new Matrix.Matrix();
rotate180.rotateZ(180);
var rotate28th = new Matrix.Matrix();
rotate28th.rotateZ(360/28);
var rotateNeg16th = new Matrix.Matrix();
rotateNeg16th.rotateZ(-360/16);
function getCircle() {
if (_circle !== null) return _circle;
_circle = circle();
return _circle;
}
function getSquare() {
if (_square !== null) return _square;
_square = square();
return _square;
}
function getSlanted() {
if (_slanted !== null) return _slanted;
_slanted = slanted();
return _slanted;
}
function getArch() {
if (_arch !== null) return _arch;
_arch = arch();
return _arch;
}
function getFan() {
if (_fan !== null) return _fan;
_fan = fan();
return _fan;
}
function getArrow() {
if (_arrow !== null) return _arrow;
_arrow = arrow();
return _arrow;
}
function getSemiCircle() {
if (_semiCircle !== null) return _semiCircle;
_semiCircle = semiCircle();
return _semiCircle;
}
function getOval() {
if (_oval !== null) return _oval;
_oval = oval();
return _oval;
}
function getPill() {
if (_pill !== null) return _pill;
_pill = pill();
return _pill;
}
function getTriangle() {
if (_triangle !== null) return _triangle;
_triangle = triangle();
return _triangle;
}
function getDiamond() {
if (_diamond !== null) return _diamond;
_diamond = diamond();
return _diamond;
}
function getClamShell() {
if (_clamShell !== null) return _clamShell;
_clamShell = clamShell();
return _clamShell;
}
function getPentagon() {
if (_pentagon !== null) return _pentagon;
_pentagon = pentagon();
return _pentagon;
}
function getGem() {
if (_gem !== null) return _gem;
_gem = gem();
return _gem;
}
function getSunny() {
if (_sunny !== null) return _sunny;
_sunny = sunny();
return _sunny;
}
function getVerySunny() {
if (_verySunny !== null) return _verySunny;
_verySunny = verySunny();
return _verySunny;
}
function getCookie4Sided() {
if (_cookie4Sided !== null) return _cookie4Sided;
_cookie4Sided = cookie4();
return _cookie4Sided;
}
function getCookie6Sided() {
if (_cookie6Sided !== null) return _cookie6Sided;
_cookie6Sided = cookie6();
return _cookie6Sided;
}
function getCookie7Sided() {
if (_cookie7Sided !== null) return _cookie7Sided;
_cookie7Sided = cookie7();
return _cookie7Sided;
}
function getCookie9Sided() {
if (_cookie9Sided !== null) return _cookie9Sided;
_cookie9Sided = cookie9();
return _cookie9Sided;
}
function getCookie12Sided() {
if (_cookie12Sided !== null) return _cookie12Sided;
_cookie12Sided = cookie12();
return _cookie12Sided;
}
function getGhostish() {
if (_ghostish !== null) return _ghostish;
_ghostish = ghostish();
return _ghostish;
}
function getClover4Leaf() {
if (_clover4Leaf !== null) return _clover4Leaf;
_clover4Leaf = clover4();
return _clover4Leaf;
}
function getClover8Leaf() {
if (_clover8Leaf !== null) return _clover8Leaf;
_clover8Leaf = clover8();
return _clover8Leaf;
}
function getBurst() {
if (_burst !== null) return _burst;
_burst = burst();
return _burst;
}
function getSoftBurst() {
if (_softBurst !== null) return _softBurst;
_softBurst = softBurst();
return _softBurst;
}
function getBoom() {
if (_boom !== null) return _boom;
_boom = boom();
return _boom;
}
function getSoftBoom() {
if (_softBoom !== null) return _softBoom;
_softBoom = softBoom();
return _softBoom;
}
function getFlower() {
if (_flower !== null) return _flower;
_flower = flower();
return _flower;
}
function getPuffy() {
if (_puffy !== null) return _puffy;
_puffy = puffy();
return _puffy;
}
function getPuffyDiamond() {
if (_puffyDiamond !== null) return _puffyDiamond;
_puffyDiamond = puffyDiamond();
return _puffyDiamond;
}
function getPixelCircle() {
if (_pixelCircle !== null) return _pixelCircle;
_pixelCircle = pixelCircle();
return _pixelCircle;
}
function getPixelTriangle() {
if (_pixelTriangle !== null) return _pixelTriangle;
_pixelTriangle = pixelTriangle();
return _pixelTriangle;
}
function getBun() {
if (_bun !== null) return _bun;
_bun = bun();
return _bun;
}
function getHeart() {
if (_heart !== null) return _heart;
_heart = heart();
return _heart;
}
function circle() {
return RoundedPolygon.RoundedPolygon.circle(10)
.transformed((x, y) => rotate45.map(new Offset.Offset(x, y)))
.normalized();
}
function square() {
return RoundedPolygon.RoundedPolygon.rectangle(1, 1, cornerRound30).normalized();
}
function slanted() {
return customPolygon([
new PointNRound(new Offset.Offset(0.926, 0.970), new CornerRounding.CornerRounding(0.189, 0.811)),
new PointNRound(new Offset.Offset(-0.021, 0.967), new CornerRounding.CornerRounding(0.187, 0.057)),
], 2).normalized();
}
function arch() {
return RoundedPolygon.RoundedPolygon.rectangle(1, 1, CornerRounding.Unrounded, [cornerRound20, cornerRound20, cornerRound100, cornerRound100])
.normalized();
}
function fan() {
return customPolygon([
new PointNRound(new Offset.Offset(1.004, 1.000), new CornerRounding.CornerRounding(0.148, 0.417)),
new PointNRound(new Offset.Offset(0.000, 1.000), new CornerRounding.CornerRounding(0.151)),
new PointNRound(new Offset.Offset(0.000, -0.003), new CornerRounding.CornerRounding(0.148)),
new PointNRound(new Offset.Offset(0.978, 0.020), new CornerRounding.CornerRounding(0.803)),
], 1).normalized();
}
function arrow() {
return customPolygon([
new PointNRound(new Offset.Offset(1.225, 1.060), new CornerRounding.CornerRounding(0.211)),
new PointNRound(new Offset.Offset(0.500, 0.892), new CornerRounding.CornerRounding(0.313)),
new PointNRound(new Offset.Offset(-0.216, 1.050), new CornerRounding.CornerRounding(0.207)),
new PointNRound(new Offset.Offset(0.499, -0.160), new CornerRounding.CornerRounding(0.215, 1.000)),
], 1).normalized();
}
function semiCircle() {
return RoundedPolygon.RoundedPolygon.rectangle(1.6, 1, CornerRounding.Unrounded, [cornerRound20, cornerRound20, cornerRound100, cornerRound100]).normalized();
}
function oval() {
const scaleMatrix = new Matrix.Matrix();
scaleMatrix.scale(1, 0.64);
return RoundedPolygon.RoundedPolygon.circle()
.transformed((x, y) => rotateNeg90.map(new Offset.Offset(x, y)))
.transformed((x, y) => scaleMatrix.map(new Offset.Offset(x, y)))
.transformed((x, y) => rotate135.map(new Offset.Offset(x, y)))
.normalized();
}
function pill() {
return customPolygon([
// new PointNRound(new Offset.Offset(0.609, 0.000), new CornerRounding.CornerRounding(1.000)),
new PointNRound(new Offset.Offset(0.428, -0.001), new CornerRounding.CornerRounding(0.426)),
new PointNRound(new Offset.Offset(0.961, 0.039), new CornerRounding.CornerRounding(0.426)),
new PointNRound(new Offset.Offset(1.001, 0.428)),
new PointNRound(new Offset.Offset(1.000, 0.609), new CornerRounding.CornerRounding(1.000)),
], 2)
.transformed((x, y) => rotate180.map(new Offset.Offset(x, y)))
.normalized();
}
function triangle() {
return RoundedPolygon.RoundedPolygon.fromNumVertices(3, 1, 0.5, 0.5, cornerRound20)
.transformed((x, y) => rotate30.map(new Offset.Offset(x, y)))
.normalized()
}
function diamond() {
return customPolygon([
new PointNRound(new Offset.Offset(0.500, 1.096), new CornerRounding.CornerRounding(0.151, 0.524)),
new PointNRound(new Offset.Offset(0.040, 0.500), new CornerRounding.CornerRounding(0.159)),
], 2).normalized();
}
function clamShell() {
return customPolygon([
new PointNRound(new Offset.Offset(0.829, 0.841), new CornerRounding.CornerRounding(0.159)),
new PointNRound(new Offset.Offset(0.171, 0.841), new CornerRounding.CornerRounding(0.159)),
new PointNRound(new Offset.Offset(-0.020, 0.500), new CornerRounding.CornerRounding(0.140)),
], 2).normalized();
}
function pentagon() {
return customPolygon([
new PointNRound(new Offset.Offset(0.828, 0.970), new CornerRounding.CornerRounding(0.169)),
new PointNRound(new Offset.Offset(0.172, 0.970), new CornerRounding.CornerRounding(0.169)),
new PointNRound(new Offset.Offset(-0.030, 0.365), new CornerRounding.CornerRounding(0.164)),
new PointNRound(new Offset.Offset(0.500, -0.009), new CornerRounding.CornerRounding(0.172)),
new PointNRound(new Offset.Offset(1.030, 0.365), new CornerRounding.CornerRounding(0.164)),
], 1).normalized();
}
function gem() {
return customPolygon([
new PointNRound(new Offset.Offset(1.005, 0.792), new CornerRounding.CornerRounding(0.208)),
new PointNRound(new Offset.Offset(0.5, 1.023), new CornerRounding.CornerRounding(0.241, 0.778)),
new PointNRound(new Offset.Offset(-0.005, 0.792), new CornerRounding.CornerRounding(0.208)),
new PointNRound(new Offset.Offset(0.073, 0.258), new CornerRounding.CornerRounding(0.228)),
new PointNRound(new Offset.Offset(0.5, 0.000), new CornerRounding.CornerRounding(0.241, 0.778)),
new PointNRound(new Offset.Offset(0.927, 0.258), new CornerRounding.CornerRounding(0.228)),
], 1).normalized();
}
function sunny() {
return RoundedPolygon.RoundedPolygon.star(8, 1, 0.8, cornerRound15)
.transformed((x, y) => rotate45.map(new Offset.Offset(x, y)))
.normalized();
}
function verySunny() {
return customPolygon([
new PointNRound(new Offset.Offset(0.500, 1.080), new CornerRounding.CornerRounding(0.085)),
new PointNRound(new Offset.Offset(0.358, 0.843), new CornerRounding.CornerRounding(0.085)),
], 8)
.transformed((x, y) => rotateNeg45.map(new Offset.Offset(x, y)))
.normalized();
}
function cookie4() {
return customPolygon([
new PointNRound(new Offset.Offset(1.237, 1.236), new CornerRounding.CornerRounding(0.258)),
new PointNRound(new Offset.Offset(0.500, 0.918), new CornerRounding.CornerRounding(0.233)),
], 4).normalized();
}
function cookie6() {
return customPolygon([
new PointNRound(new Offset.Offset(0.723, 0.884), new CornerRounding.CornerRounding(0.394)),
new PointNRound(new Offset.Offset(0.500, 1.099), new CornerRounding.CornerRounding(0.398)),
], 6).normalized();
}
function cookie7() {
return RoundedPolygon.RoundedPolygon.star(7, 1, 0.75, cornerRound50)
.normalized()
.transformed((x, y) => rotate28th.map(new Offset.Offset(x, y)))
.transformed((x, y) => rotate28th.map(new Offset.Offset(x, y)))
.transformed((x, y) => rotate28th.map(new Offset.Offset(x, y)))
.transformed((x, y) => rotate28th.map(new Offset.Offset(x, y)))
.transformed((x, y) => rotate28th.map(new Offset.Offset(x, y)))
.normalized();
}
function cookie9() {
return RoundedPolygon.RoundedPolygon.star(9, 1, 0.8, cornerRound50)
.transformed((x, y) => rotate30.map(new Offset.Offset(x, y)))
.normalized();
}
function cookie12() {
return RoundedPolygon.RoundedPolygon.star(12, 1, 0.8, cornerRound50)
.transformed((x, y) => rotate30.map(new Offset.Offset(x, y)))
.normalized();
}
function ghostish() {
return customPolygon([
new PointNRound(new Offset.Offset(1.000, 1.140), new CornerRounding.CornerRounding(0.254, 0.106)),
new PointNRound(new Offset.Offset(0.575, 0.906), new CornerRounding.CornerRounding(0.253)),
new PointNRound(new Offset.Offset(0.425, 0.906), new CornerRounding.CornerRounding(0.253)),
new PointNRound(new Offset.Offset(0.000, 1.140), new CornerRounding.CornerRounding(0.254, 0.106)),
new PointNRound(new Offset.Offset(0.000, 0.000), new CornerRounding.CornerRounding(1.0)),
new PointNRound(new Offset.Offset(0.500, 0.000), new CornerRounding.CornerRounding(1.0)),
new PointNRound(new Offset.Offset(1.000, 0.000), new CornerRounding.CornerRounding(1.0)),
], 1).normalized();
}
function clover4() {
return customPolygon([
new PointNRound(new Offset.Offset(1.099, 0.725), new CornerRounding.CornerRounding(0.476)),
new PointNRound(new Offset.Offset(0.725, 1.099), new CornerRounding.CornerRounding(0.476)),
new PointNRound(new Offset.Offset(0.500, 0.926)),
], 4).normalized();
}
function clover8() {
return customPolygon([
new PointNRound(new Offset.Offset(0.758, 1.101), new CornerRounding.CornerRounding(0.209)),
new PointNRound(new Offset.Offset(0.500, 0.964)),
], 8).normalized();
}
function burst() {
return customPolygon([
new PointNRound(new Offset.Offset(0.592, 0.842), new CornerRounding.CornerRounding(0.006)),
new PointNRound(new Offset.Offset(0.500, 1.006), new CornerRounding.CornerRounding(0.006)),
], 12)
.transformed((x, y) => rotateNeg30.map(new Offset.Offset(x, y)))
.transformed((x, y) => rotateNeg30.map(new Offset.Offset(x, y)))
.normalized();
}
function softBurst() {
return customPolygon([
new PointNRound(new Offset.Offset(0.193, 0.277), new CornerRounding.CornerRounding(0.053)),
new PointNRound(new Offset.Offset(0.176, 0.055), new CornerRounding.CornerRounding(0.053)),
], 10)
.transformed((x, y) => rotate180.map(new Offset.Offset(x, y)))
.normalized();
}
function boom() {
return customPolygon([
new PointNRound(new Offset.Offset(0.457, 0.296), new CornerRounding.CornerRounding(0.007)),
new PointNRound(new Offset.Offset(0.500, -0.051), new CornerRounding.CornerRounding(0.007)),
], 15)
.transformed((x, y) => rotate120.map(new Offset.Offset(x, y)))
.normalized();
}
function softBoom() {
return customPolygon([
new PointNRound(new Offset.Offset(0.733, 0.454)),
new PointNRound(new Offset.Offset(0.839, 0.437), new CornerRounding.CornerRounding(0.532)),
new PointNRound(new Offset.Offset(0.949, 0.449), new CornerRounding.CornerRounding(0.439, 1.000)),
new PointNRound(new Offset.Offset(0.998, 0.478), new CornerRounding.CornerRounding(0.174)),
// mirrored points
new PointNRound(new Offset.Offset(0.998, 0.522), new CornerRounding.CornerRounding(0.174)),
new PointNRound(new Offset.Offset(0.949, 0.551), new CornerRounding.CornerRounding(0.439, 1.000)),
new PointNRound(new Offset.Offset(0.839, 0.563), new CornerRounding.CornerRounding(0.532)),
new PointNRound(new Offset.Offset(0.733, 0.546)),
], 16)
.transformed((x, y) => rotate45.map(new Offset.Offset(x, y)))
.transformed((x, y) => rotateNeg16th.map(new Offset.Offset(x, y)))
.normalized();
}
function flower() {
return customPolygon([
new PointNRound(new Offset.Offset(0.370, 0.187)),
new PointNRound(new Offset.Offset(0.416, 0.049), new CornerRounding.CornerRounding(0.381)),
new PointNRound(new Offset.Offset(0.479, 0.001), new CornerRounding.CornerRounding(0.095)),
// mirrored points
new PointNRound(new Offset.Offset(0.521, 0.001), new CornerRounding.CornerRounding(0.095)),
new PointNRound(new Offset.Offset(0.584, 0.049), new CornerRounding.CornerRounding(0.381)),
new PointNRound(new Offset.Offset(0.630, 0.187)),
], 8)
.transformed((x, y) => rotate135.map(new Offset.Offset(x, y)))
.normalized();
}
function puffy() {
const m = new Matrix.Matrix();
m.scale(1, 0.742);
const shape = customPolygon([
// mirrored points
new PointNRound(new Offset.Offset(1.003, 0.563), new CornerRounding.CornerRounding(0.255)),
new PointNRound(new Offset.Offset(0.940, 0.656), new CornerRounding.CornerRounding(0.126)),
new PointNRound(new Offset.Offset(0.881, 0.654)),
new PointNRound(new Offset.Offset(0.926, 0.711), new CornerRounding.CornerRounding(0.660)),
new PointNRound(new Offset.Offset(0.914, 0.851), new CornerRounding.CornerRounding(0.660)),
new PointNRound(new Offset.Offset(0.777, 0.998), new CornerRounding.CornerRounding(0.360)),
new PointNRound(new Offset.Offset(0.722, 0.872)),
new PointNRound(new Offset.Offset(0.717, 0.934), new CornerRounding.CornerRounding(0.574)),
new PointNRound(new Offset.Offset(0.670, 1.035), new CornerRounding.CornerRounding(0.426)),
new PointNRound(new Offset.Offset(0.545, 1.040), new CornerRounding.CornerRounding(0.405)),
new PointNRound(new Offset.Offset(0.500, 0.947)),
// original points
new PointNRound(new Offset.Offset(0.500, 1-0.053)),
new PointNRound(new Offset.Offset(1-0.545, 1+0.040), new CornerRounding.CornerRounding(0.405)),
new PointNRound(new Offset.Offset(1-0.670, 1+0.035), new CornerRounding.CornerRounding(0.426)),
new PointNRound(new Offset.Offset(1-0.717, 1-0.066), new CornerRounding.CornerRounding(0.574)),
new PointNRound(new Offset.Offset(1-0.722, 1-0.128)),
new PointNRound(new Offset.Offset(1-0.777, 1-0.002), new CornerRounding.CornerRounding(0.360)),
new PointNRound(new Offset.Offset(1-0.914, 1-0.149), new CornerRounding.CornerRounding(0.660)),
new PointNRound(new Offset.Offset(1-0.926, 1-0.289), new CornerRounding.CornerRounding(0.660)),
new PointNRound(new Offset.Offset(1-0.881, 1-0.346)),
new PointNRound(new Offset.Offset(1-0.940, 1-0.344), new CornerRounding.CornerRounding(0.126)),
new PointNRound(new Offset.Offset(1-1.003, 1-0.437), new CornerRounding.CornerRounding(0.255)),
], 2);
return shape.transformed((x, y) => m.map(new Offset.Offset(x, y))).normalized();
}
function puffyDiamond() {
return customPolygon([
// original points
new PointNRound(new Offset.Offset(0.870, 0.130), new CornerRounding.CornerRounding(0.146)),
new PointNRound(new Offset.Offset(0.818, 0.357)),
new PointNRound(new Offset.Offset(1.000, 0.332), new CornerRounding.CornerRounding(0.853)),
// mirrored points
new PointNRound(new Offset.Offset(1.000, 1-0.332), new CornerRounding.CornerRounding(0.853)),
new PointNRound(new Offset.Offset(0.818, 1-0.357)),
], 4)
.transformed((x, y) => rotate90.map(new Offset.Offset(x, y)))
.normalized();
}
function pixelCircle() {
return customPolygon([
new PointNRound(new Offset.Offset(1.000, 0.704)),
new PointNRound(new Offset.Offset(0.926, 0.704)),
new PointNRound(new Offset.Offset(0.926, 0.852)),
new PointNRound(new Offset.Offset(0.843, 0.852)),
new PointNRound(new Offset.Offset(0.843, 0.935)),
new PointNRound(new Offset.Offset(0.704, 0.935)),
new PointNRound(new Offset.Offset(0.704, 1.000)),
new PointNRound(new Offset.Offset(0.500, 1.000)),
new PointNRound(new Offset.Offset(1-0.704, 1.000)),
new PointNRound(new Offset.Offset(1-0.704, 0.935)),
new PointNRound(new Offset.Offset(1-0.843, 0.935)),
new PointNRound(new Offset.Offset(1-0.843, 0.852)),
new PointNRound(new Offset.Offset(1-0.926, 0.852)),
new PointNRound(new Offset.Offset(1-0.926, 0.704)),
new PointNRound(new Offset.Offset(1-1.000, 0.704)),
], 2)
.normalized();
}
function pixelTriangle() {
return customPolygon([
// mirrored points
new PointNRound(new Offset.Offset(0.888, 1-0.439)),
new PointNRound(new Offset.Offset(0.789, 1-0.439)),
new PointNRound(new Offset.Offset(0.789, 1-0.344)),
new PointNRound(new Offset.Offset(0.675, 1-0.344)),
new PointNRound(new Offset.Offset(0.674, 1-0.265)),
new PointNRound(new Offset.Offset(0.560, 1-0.265)),
new PointNRound(new Offset.Offset(0.560, 1-0.170)),
new PointNRound(new Offset.Offset(0.421, 1-0.170)),
new PointNRound(new Offset.Offset(0.421, 1-0.087)),
new PointNRound(new Offset.Offset(0.287, 1-0.087)),
new PointNRound(new Offset.Offset(0.287, 1-0.000)),
new PointNRound(new Offset.Offset(0.113, 1-0.000)),
// original points
new PointNRound(new Offset.Offset(0.110, 0.500)),
new PointNRound(new Offset.Offset(0.113, 0.000)),
new PointNRound(new Offset.Offset(0.287, 0.000)),
new PointNRound(new Offset.Offset(0.287, 0.087)),
new PointNRound(new Offset.Offset(0.421, 0.087)),
new PointNRound(new Offset.Offset(0.421, 0.170)),
new PointNRound(new Offset.Offset(0.560, 0.170)),
new PointNRound(new Offset.Offset(0.560, 0.265)),
new PointNRound(new Offset.Offset(0.674, 0.265)),
new PointNRound(new Offset.Offset(0.675, 0.344)),
new PointNRound(new Offset.Offset(0.789, 0.344)),
new PointNRound(new Offset.Offset(0.789, 0.439)),
new PointNRound(new Offset.Offset(0.888, 0.439)),
], 1).normalized();
}
function bun() {
return customPolygon([
// original points
new PointNRound(new Offset.Offset(0.796, 0.500)),
new PointNRound(new Offset.Offset(0.853, 0.518), cornerRound100),
new PointNRound(new Offset.Offset(0.992, 0.631), cornerRound100),
new PointNRound(new Offset.Offset(0.968, 1.000), cornerRound100),
// mirrored points
new PointNRound(new Offset.Offset(0.032, 1-0.000), cornerRound100),
new PointNRound(new Offset.Offset(0.008, 1-0.369), cornerRound100),
new PointNRound(new Offset.Offset(0.147, 1-0.482), cornerRound100),
new PointNRound(new Offset.Offset(0.204, 1-0.500)),
], 2).normalized();
}
function heart() {
return customPolygon([
new PointNRound(new Offset.Offset(0.782, 0.611)),
new PointNRound(new Offset.Offset(0.499, 0.946), new CornerRounding.CornerRounding(0.000)),
new PointNRound(new Offset.Offset(0.2175, 0.611)),
new PointNRound(new Offset.Offset(-0.064, 0.276), new CornerRounding.CornerRounding(1.000)),
new PointNRound(new Offset.Offset(0.208, -0.066), new CornerRounding.CornerRounding(0.958)),
new PointNRound(new Offset.Offset(0.500, 0.268), new CornerRounding.CornerRounding(0.016)),
new PointNRound(new Offset.Offset(0.792, -0.066), new CornerRounding.CornerRounding(0.958)),
new PointNRound(new Offset.Offset(1.064, 0.276), new CornerRounding.CornerRounding(1.000)),
], 1)
.normalized();
}
class PointNRound {
constructor(o, r = CornerRounding.Unrounded) {
this.o = o;
this.r = r;
}
}
function doRepeat(points, reps, center, mirroring) {
if (mirroring) {
const result = [];
const angles = points.map(p => p.o.minus(center).angleDegrees());
const distances = points.map(p => p.o.minus(center).getDistance());
const actualReps = reps * 2;
const sectionAngle = 360 / actualReps;
for (let it = 0; it < actualReps; it++) {
for (let index = 0; index < points.length; index++) {
const i = (it % 2 === 0) ? index : points.length - 1 - index;
if (i > 0 || it % 2 === 0) {
const baseAngle = angles[i];
const angle = it * sectionAngle + (it % 2 === 0 ? baseAngle : (2 * angles[0] - baseAngle));
const dist = distances[i];
const rad = angle * Math.PI / 180;
const x = center.x + dist * Math.cos(rad);
const y = center.y + dist * Math.sin(rad);
result.push(new PointNRound(new Offset.Offset(x, y), points[i].r));
}
}
}
return result;
} else {
const np = points.length;
const result = [];
for (let i = 0; i < np * reps; i++) {
const point = points[i % np].o.rotateDegrees(Math.floor(i / np) * 360 / reps, center);
result.push(new PointNRound(point, points[i % np].r));
}
return result;
}
}
function customPolygon(pnr, reps = 1, center = new Offset.Offset(0.5, 0.5), mirroring = false) {
const actualPoints = doRepeat(pnr, reps, center, mirroring);
const vertices = [];
for (const p of actualPoints) {
vertices.push(p.o.x);
vertices.push(p.o.y);
}
const perVertexRounding = actualPoints.map(p => p.r);
return RoundedPolygon.RoundedPolygon.fromVertices(vertices, CornerRounding.Unrounded, perVertexRounding, center.x, center.y);
}

View File

@@ -0,0 +1,18 @@
.pragma library
/**
* Represents corner rounding configuration
*/
class CornerRounding {
/**
* @param {float} [radius=0]
* @param {float} [smoothing=0]
*/
constructor(radius = 0, smoothing = 0) {
this.radius = radius;
this.smoothing = smoothing;
}
}
// Static property
CornerRounding.Unrounded = new CornerRounding();

View File

@@ -0,0 +1,371 @@
.pragma library
.import "point.js" as PointModule
.import "utils.js" as UtilsModule
var Point = PointModule.Point;
var DistanceEpsilon = UtilsModule.DistanceEpsilon;
var interpolate = UtilsModule.interpolate;
var directionVector = UtilsModule.directionVector;
var distance = UtilsModule.distance;
/**
* Represents a cubic Bézier curve with anchor and control points
*/
class Cubic {
/**
* @param {Array<float>} points Array of 8 numbers [anchor0X, anchor0Y, control0X, control0Y, control1X, control1Y, anchor1X, anchor1Y]
*/
constructor(points) {
this.points = points;
}
get anchor0X() { return this.points[0]; }
get anchor0Y() { return this.points[1]; }
get control0X() { return this.points[2]; }
get control0Y() { return this.points[3]; }
get control1X() { return this.points[4]; }
get control1Y() { return this.points[5]; }
get anchor1X() { return this.points[6]; }
get anchor1Y() { return this.points[7]; }
/**
* @param {Point} anchor0
* @param {Point} control0
* @param {Point} control1
* @param {Point} anchor1
* @returns {Cubic}
*/
static create(anchor0, control0, control1, anchor1) {
return new Cubic([
anchor0.x, anchor0.y,
control0.x, control0.y,
control1.x, control1.y,
anchor1.x, anchor1.y
]);
}
/**
* @param {float} t
* @returns {Point}
*/
pointOnCurve(t) {
const u = 1 - t;
return new Point(
this.anchor0X * (u * u * u) +
this.control0X * (3 * t * u * u) +
this.control1X * (3 * t * t * u) +
this.anchor1X * (t * t * t),
this.anchor0Y * (u * u * u) +
this.control0Y * (3 * t * u * u) +
this.control1Y * (3 * t * t * u) +
this.anchor1Y * (t * t * t)
);
}
/**
* @returns {boolean}
*/
zeroLength() {
return Math.abs(this.anchor0X - this.anchor1X) < DistanceEpsilon &&
Math.abs(this.anchor0Y - this.anchor1Y) < DistanceEpsilon;
}
/**
* @param {Cubic} next
* @returns {boolean}
*/
convexTo(next) {
const prevVertex = new Point(this.anchor0X, this.anchor0Y);
const currVertex = new Point(this.anchor1X, this.anchor1Y);
const nextVertex = new Point(next.anchor1X, next.anchor1Y);
return convex(prevVertex, currVertex, nextVertex);
}
/**
* @param {float} value
* @returns {boolean}
*/
zeroIsh(value) {
return Math.abs(value) < DistanceEpsilon;
}
/**
* @param {Array<float>} bounds
* @param {boolean} [approximate=false]
*/
calculateBounds(bounds, approximate = false) {
if (this.zeroLength()) {
bounds[0] = this.anchor0X;
bounds[1] = this.anchor0Y;
bounds[2] = this.anchor0X;
bounds[3] = this.anchor0Y;
return;
}
let minX = Math.min(this.anchor0X, this.anchor1X);
let minY = Math.min(this.anchor0Y, this.anchor1Y);
let maxX = Math.max(this.anchor0X, this.anchor1X);
let maxY = Math.max(this.anchor0Y, this.anchor1Y);
if (approximate) {
bounds[0] = Math.min(minX, Math.min(this.control0X, this.control1X));
bounds[1] = Math.min(minY, Math.min(this.control0Y, this.control1Y));
bounds[2] = Math.max(maxX, Math.max(this.control0X, this.control1X));
bounds[3] = Math.max(maxY, Math.max(this.control0Y, this.control1Y));
return;
}
// Find extrema using derivatives
const xa = -this.anchor0X + 3 * this.control0X - 3 * this.control1X + this.anchor1X;
const xb = 2 * this.anchor0X - 4 * this.control0X + 2 * this.control1X;
const xc = -this.anchor0X + this.control0X;
if (this.zeroIsh(xa)) {
if (xb != 0) {
const t = 2 * xc / (-2 * xb);
if (t >= 0 && t <= 1) {
const it = this.pointOnCurve(t).x;
if (it < minX) minX = it;
if (it > maxX) maxX = it;
}
}
} else {
const xs = xb * xb - 4 * xa * xc;
if (xs >= 0) {
const t1 = (-xb + Math.sqrt(xs)) / (2 * xa);
if (t1 >= 0 && t1 <= 1) {
const it = this.pointOnCurve(t1).x;
if (it < minX) minX = it;
if (it > maxX) maxX = it;
}
const t2 = (-xb - Math.sqrt(xs)) / (2 * xa);
if (t2 >= 0 && t2 <= 1) {
const it = this.pointOnCurve(t2).x;
if (it < minX) minX = it;
if (it > maxX) maxX = it;
}
}
}
// Repeat for y coord
const ya = -this.anchor0Y + 3 * this.control0Y - 3 * this.control1Y + this.anchor1Y;
const yb = 2 * this.anchor0Y - 4 * this.control0Y + 2 * this.control1Y;
const yc = -this.anchor0Y + this.control0Y;
if (this.zeroIsh(ya)) {
if (yb != 0) {
const t = 2 * yc / (-2 * yb);
if (t >= 0 && t <= 1) {
const it = this.pointOnCurve(t).y;
if (it < minY) minY = it;
if (it > maxY) maxY = it;
}
}
} else {
const ys = yb * yb - 4 * ya * yc;
if (ys >= 0) {
const t1 = (-yb + Math.sqrt(ys)) / (2 * ya);
if (t1 >= 0 && t1 <= 1) {
const it = this.pointOnCurve(t1).y;
if (it < minY) minY = it;
if (it > maxY) maxY = it;
}
const t2 = (-yb - Math.sqrt(ys)) / (2 * ya);
if (t2 >= 0 && t2 <= 1) {
const it = this.pointOnCurve(t2).y;
if (it < minY) minY = it;
if (it > maxY) maxY = it;
}
}
}
bounds[0] = minX;
bounds[1] = minY;
bounds[2] = maxX;
bounds[3] = maxY;
}
/**
* @param {float} t
* @returns {{a: Cubic, b: Cubic}}
*/
split(t) {
const u = 1 - t;
const pointOnCurve = this.pointOnCurve(t);
return {
a: new Cubic([
this.anchor0X,
this.anchor0Y,
this.anchor0X * u + this.control0X * t,
this.anchor0Y * u + this.control0Y * t,
this.anchor0X * (u * u) + this.control0X * (2 * u * t) + this.control1X * (t * t),
this.anchor0Y * (u * u) + this.control0Y * (2 * u * t) + this.control1Y * (t * t),
pointOnCurve.x,
pointOnCurve.y
]),
b: new Cubic([
pointOnCurve.x,
pointOnCurve.y,
this.control0X * (u * u) + this.control1X * (2 * u * t) + this.anchor1X * (t * t),
this.control0Y * (u * u) + this.control1Y * (2 * u * t) + this.anchor1Y * (t * t),
this.control1X * u + this.anchor1X * t,
this.control1Y * u + this.anchor1Y * t,
this.anchor1X,
this.anchor1Y
])
};
}
/**
* @returns {Cubic}
*/
reverse() {
return new Cubic([
this.anchor1X, this.anchor1Y,
this.control1X, this.control1Y,
this.control0X, this.control0Y,
this.anchor0X, this.anchor0Y
]);
}
/**
* @param {Cubic} other
* @returns {Cubic}
*/
plus(other) {
return new Cubic(other.points.map((_, index) => this.points[index] + other.points[index]));
}
/**
* @param {float} x
* @returns {Cubic}
*/
times(x) {
return new Cubic(this.points.map(v => v * x));
}
/**
* @param {float} x
* @returns {Cubic}
*/
div(x) {
return this.times(1 / x);
}
/**
* @param {Cubic} other
* @returns {boolean}
*/
equals(other) {
return this.points.every((p, i) => other.points[i] === p);
}
/**
* @param {function(float, float): Point} f
* @returns {Cubic}
*/
transformed(f) {
const newCubic = new MutableCubic([...this.points]);
newCubic.transform(f);
return newCubic;
}
/**
* @param {float} x0
* @param {float} y0
* @param {float} x1
* @param {float} y1
* @returns {Cubic}
*/
static straightLine(x0, y0, x1, y1) {
return new Cubic([
x0,
y0,
interpolate(x0, x1, 1/3),
interpolate(y0, y1, 1/3),
interpolate(x0, x1, 2/3),
interpolate(y0, y1, 2/3),
x1,
y1
]);
}
/**
* @param {float} centerX
* @param {float} centerY
* @param {float} x0
* @param {float} y0
* @param {float} x1
* @param {float} y1
* @returns {Cubic}
*/
static circularArc(centerX, centerY, x0, y0, x1, y1) {
const p0d = directionVector(x0 - centerX, y0 - centerY);
const p1d = directionVector(x1 - centerX, y1 - centerY);
const rotatedP0 = p0d.rotate90();
const rotatedP1 = p1d.rotate90();
const clockwise = rotatedP0.dotProductScalar(x1 - centerX, y1 - centerY) >= 0;
const cosa = p0d.dotProduct(p1d);
if (cosa > 0.999) {
return Cubic.straightLine(x0, y0, x1, y1);
}
const k = distance(x0 - centerX, y0 - centerY) * 4/3 *
(Math.sqrt(2 * (1 - cosa)) - Math.sqrt(1 - cosa * cosa)) /
(1 - cosa) * (clockwise ? 1 : -1);
return new Cubic([
x0, y0,
x0 + rotatedP0.x * k,
y0 + rotatedP0.y * k,
x1 - rotatedP1.x * k,
y1 - rotatedP1.y * k,
x1, y1
]);
}
/**
* @param {float} x0
* @param {float} y0
* @returns {Cubic}
*/
static empty(x0, y0) {
return new Cubic([x0, y0, x0, y0, x0, y0, x0, y0]);
}
}
class MutableCubic extends Cubic {
/**
* @param {function(float, float): Point} f
*/
transform(f) {
this.transformOnePoint(f, 0);
this.transformOnePoint(f, 2);
this.transformOnePoint(f, 4);
this.transformOnePoint(f, 6);
}
/**
* @param {Cubic} c1
* @param {Cubic} c2
* @param {float} progress
*/
interpolate(c1, c2, progress) {
for (let i = 0; i < 8; i++) {
this.points[i] = interpolate(c1.points[i], c2.points[i], progress);
}
}
/**
* @private
* @param {function(float, float): Point} f
* @param {number} ix
*/
transformOnePoint(f, ix) {
const result = f(this.points[ix], this.points[ix + 1]);
this.points[ix] = result.x;
this.points[ix + 1] = result.y;
}
}

View File

@@ -0,0 +1,166 @@
.pragma library
.import "feature.js" as FeatureModule
.import "float-mapping.js" as MappingModule
.import "point.js" as PointModule
.import "utils.js" as UtilsModule
var Feature = FeatureModule.Feature;
var Corner = FeatureModule.Corner;
var Point = PointModule.Point;
var DoubleMapper = MappingModule.DoubleMapper;
var progressInRange = MappingModule.progressInRange;
var DistanceEpsilon = UtilsModule.DistanceEpsilon;
var IdentityMapping = [{ a: 0, b: 0 }, { a: 0.5, b: 0.5 }];
class ProgressableFeature {
/**
* @param {float} progress
* @param {Feature} feature
*/
constructor(progress, feature) {
this.progress = progress;
this.feature = feature;
}
}
class DistanceVertex {
/**
* @param {float} distance
* @param {ProgressableFeature} f1
* @param {ProgressableFeature} f2
*/
constructor(distance, f1, f2) {
this.distance = distance;
this.f1 = f1;
this.f2 = f2;
}
}
class MappingHelper {
constructor() {
this.mapping = [];
this.usedF1 = new Set();
this.usedF2 = new Set();
}
/**
* @param {ProgressableFeature} f1
* @param {ProgressableFeature} f2
*/
addMapping(f1, f2) {
if (this.usedF1.has(f1) || this.usedF2.has(f2)) {
return;
}
const index = this.mapping.findIndex(x => x.a === f1.progress);
const insertionIndex = -index - 1;
const n = this.mapping.length;
if (n >= 1) {
const { a: before1, b: before2 } = this.mapping[(insertionIndex + n - 1) % n];
const { a: after1, b: after2 } = this.mapping[insertionIndex % n];
if (
progressDistance(f1.progress, before1) < DistanceEpsilon ||
progressDistance(f1.progress, after1) < DistanceEpsilon ||
progressDistance(f2.progress, before2) < DistanceEpsilon ||
progressDistance(f2.progress, after2) < DistanceEpsilon
) {
return;
}
if (n > 1 && !progressInRange(f2.progress, before2, after2)) {
return;
}
}
this.mapping.splice(insertionIndex, 0, { a: f1.progress, b: f2.progress });
this.usedF1.add(f1);
this.usedF2.add(f2);
}
}
/**
* @param {Array<ProgressableFeature>} features1
* @param {Array<ProgressableFeature>} features2
* @returns {DoubleMapper}
*/
function featureMapper(features1, features2) {
const filteredFeatures1 = features1.filter(f => f.feature instanceof Corner);
const filteredFeatures2 = features2.filter(f => f.feature instanceof Corner);
const featureProgressMapping = doMapping(filteredFeatures1, filteredFeatures2);
return new DoubleMapper(...featureProgressMapping);
}
/**
* @param {Array<ProgressableFeature>} features1
* @param {Array<ProgressableFeature>} features2
* @returns {Array<{a: float, b: float}>}
*/
function doMapping(features1, features2) {
const distanceVertexList = [];
for (const f1 of features1) {
for (const f2 of features2) {
const d = featureDistSquared(f1.feature, f2.feature);
if (d !== Number.MAX_VALUE) {
distanceVertexList.push(new DistanceVertex(d, f1, f2));
}
}
}
distanceVertexList.sort((a, b) => a.distance - b.distance);
// Special cases
if (distanceVertexList.length === 0) {
return IdentityMapping;
} else if (distanceVertexList.length === 1) {
const { f1, f2 } = distanceVertexList[0];
const p1 = f1.progress;
const p2 = f2.progress;
return [
{ a: p1, b: p2 },
{ a: (p1 + 0.5) % 1, b: (p2 + 0.5) % 1 }
];
}
const helper = new MappingHelper();
distanceVertexList.forEach(({ f1, f2 }) => helper.addMapping(f1, f2));
return helper.mapping;
}
/**
* @param {Feature} f1
* @param {Feature} f2
* @returns {float}
*/
function featureDistSquared(f1, f2) {
if (f1 instanceof Corner && f2 instanceof Corner && f1.convex != f2.convex) {
return Number.MAX_VALUE;
}
return featureRepresentativePoint(f1).minus(featureRepresentativePoint(f2)).getDistanceSquared();
}
/**
* @param {Feature} feature
* @returns {Point}
*/
function featureRepresentativePoint(feature) {
const firstCubic = feature.cubics[0];
const lastCubic = feature.cubics[feature.cubics.length - 1];
const x = (firstCubic.anchor0X + lastCubic.anchor1X) / 2;
const y = (firstCubic.anchor0Y + lastCubic.anchor1Y) / 2;
return new Point(x, y);
}
/**
* @param {float} p1
* @param {float} p2
* @returns {float}
*/
function progressDistance(p1, p2) {
const it = Math.abs(p1 - p2);
return Math.min(it, 1 - it);
}

View File

@@ -0,0 +1,103 @@
.pragma library
.import "cubic.js" as CubicModule
var Cubic = CubicModule.Cubic;
/**
* Base class for shape features (edges and corners)
*/
class Feature {
/**
* @param {Array<Cubic>} cubics
*/
constructor(cubics) {
this.cubics = cubics;
}
/**
* @param {Array<Cubic>} cubics
* @returns {Edge}
*/
buildIgnorableFeature(cubics) {
return new Edge(cubics);
}
/**
* @param {Cubic} cubic
* @returns {Edge}
*/
buildEdge(cubic) {
return new Edge([cubic]);
}
/**
* @param {Array<Cubic>} cubics
* @returns {Corner}
*/
buildConvexCorner(cubics) {
return new Corner(cubics, true);
}
/**
* @param {Array<Cubic>} cubics
* @returns {Corner}
*/
buildConcaveCorner(cubics) {
return new Corner(cubics, false);
}
}
class Edge extends Feature {
constructor(cubics) {
super(cubics);
this.isIgnorableFeature = true;
this.isEdge = true;
this.isConvexCorner = false;
this.isConcaveCorner = false;
}
/**
* @param {function(float, float): Point} f
* @returns {Feature}
*/
transformed(f) {
return new Edge(this.cubics.map(c => c.transformed(f)));
}
/**
* @returns {Feature}
*/
reversed() {
return new Edge(this.cubics.map(c => c.reverse()));
}
}
class Corner extends Feature {
/**
* @param {Array<Cubic>} cubics
* @param {boolean} convex
*/
constructor(cubics, convex) {
super(cubics);
this.convex = convex;
this.isIgnorableFeature = false;
this.isEdge = false;
this.isConvexCorner = convex;
this.isConcaveCorner = !convex;
}
/**
* @param {function(float, float): Point} f
* @returns {Feature}
*/
transformed(f) {
return new Corner(this.cubics.map(c => c.transformed(f)), this.convex);
}
/**
* @returns {Feature}
*/
reversed() {
return new Corner(this.cubics.map(c => c.reverse()), !this.convex);
}
}

View File

@@ -0,0 +1,86 @@
.pragma library
.import "utils.js" as UtilsModule
var positiveModulo = UtilsModule.positiveModulo;
/**
* Maps values between two ranges
*/
class DoubleMapper {
constructor(...mappings) {
this.sourceValues = [];
this.targetValues = [];
for (const mapping of mappings) {
this.sourceValues.push(mapping.a);
this.targetValues.push(mapping.b);
}
}
/**
* @param {float} x
* @returns {float}
*/
map(x) {
return linearMap(this.sourceValues, this.targetValues, x);
}
/**
* @param {float} x
* @returns {float}
*/
mapBack(x) {
return linearMap(this.targetValues, this.sourceValues, x);
}
}
// Static property
DoubleMapper.Identity = new DoubleMapper({ a: 0, b: 0 }, { a: 0.5, b: 0.5 });
/**
* @param {Array<float>} xValues
* @param {Array<float>} yValues
* @param {float} x
* @returns {float}
*/
function linearMap(xValues, yValues, x) {
let segmentStartIndex = -1;
for (let i = 0; i < xValues.length; i++) {
const nextIndex = (i + 1) % xValues.length;
if (progressInRange(x, xValues[i], xValues[nextIndex])) {
segmentStartIndex = i;
break;
}
}
if (segmentStartIndex === -1) {
throw new Error("No valid segment found");
}
const segmentEndIndex = (segmentStartIndex + 1) % xValues.length;
const segmentSizeX = positiveModulo(xValues[segmentEndIndex] - xValues[segmentStartIndex], 1);
const segmentSizeY = positiveModulo(yValues[segmentEndIndex] - yValues[segmentStartIndex], 1);
let positionInSegment;
if (segmentSizeX < 0.001) {
positionInSegment = 0.5;
} else {
positionInSegment = positiveModulo(x - xValues[segmentStartIndex], 1) / segmentSizeX;
}
return positiveModulo(yValues[segmentStartIndex] + segmentSizeY * positionInSegment, 1);
}
/**
* @param {float} progress
* @param {float} progressFrom
* @param {float} progressTo
* @returns {boolean}
*/
function progressInRange(progress, progressFrom, progressTo) {
if (progressTo >= progressFrom) {
return progress >= progressFrom && progress <= progressTo;
} else {
return progress >= progressFrom || progress <= progressTo;
}
}

View File

@@ -0,0 +1,94 @@
.pragma library
.import "rounded-polygon.js" as RoundedPolygon
.import "cubic.js" as Cubic
.import "polygon-measure.js" as PolygonMeasure
.import "feature-mapping.js" as FeatureMapping
.import "utils.js" as Utils
class Morph {
constructor(start, end) {
this.morphMatch = this.match(start, end)
}
asCubics(progress) {
const ret = []
// The first/last mechanism here ensures that the final anchor point in the shape
// exactly matches the first anchor point. There can be rendering artifacts introduced
// by those points being slightly off, even by much less than a pixel
let firstCubic = null
let lastCubic = null
for (let i = 0; i < this.morphMatch.length; i++) {
const cubic = new Cubic.Cubic(Array.from({ length: 8 }).map((_, it) => Utils.interpolate(
this.morphMatch[i].a.points[it],
this.morphMatch[i].b.points[it],
progress,
)))
if (firstCubic == null)
firstCubic = cubic
if (lastCubic != null)
ret.push(lastCubic)
lastCubic = cubic
}
if (lastCubic != null && firstCubic != null)
ret.push(
new Cubic.Cubic([
lastCubic.anchor0X,
lastCubic.anchor0Y,
lastCubic.control0X,
lastCubic.control0Y,
lastCubic.control1X,
lastCubic.control1Y,
firstCubic.anchor0X,
firstCubic.anchor0Y,
])
)
return ret
}
forEachCubic(progress, mutableCubic, callback) {
for (let i = 0; i < this.morphMatch.length; i++) {
mutableCubic.interpolate(this.morphMatch[i].a, this.morphMatch[i].b, progress)
callback(mutableCubic)
}
}
match(p1, p2) {
const measurer = new PolygonMeasure.LengthMeasurer()
const measuredPolygon1 = PolygonMeasure.MeasuredPolygon.measurePolygon(measurer, p1)
const measuredPolygon2 = PolygonMeasure.MeasuredPolygon.measurePolygon(measurer, p2)
const features1 = measuredPolygon1.features
const features2 = measuredPolygon2.features
const doubleMapper = FeatureMapping.featureMapper(features1, features2)
const polygon2CutPoint = doubleMapper.map(0)
const bs1 = measuredPolygon1
const bs2 = measuredPolygon2.cutAndShift(polygon2CutPoint)
const ret = []
let i1 = 0
let i2 = 0
let b1 = bs1.cubics[i1++]
let b2 = bs2.cubics[i2++]
while (b1 != null && b2 != null) {
const b1a = (i1 == bs1.cubics.length) ? 1 : b1.endOutlineProgress
const b2a = (i2 == bs2.cubics.length) ? 1 : doubleMapper.mapBack(Utils.positiveModulo(b2.endOutlineProgress + polygon2CutPoint, 1))
const minb = Math.min(b1a, b2a)
const { a: seg1, b: newb1 } = b1a > minb + Utils.AngleEpsilon ? b1.cutAtProgress(minb) : { a: b1, b: bs1.cubics[i1++] }
const { a: seg2, b: newb2 } = b2a > minb + Utils.AngleEpsilon ? b2.cutAtProgress(Utils.positiveModulo(doubleMapper.map(minb) - polygon2CutPoint, 1)) : { a: b2, b: bs2.cubics[i2++] }
ret.push({ a: seg1.cubic, b: seg2.cubic })
b1 = newb1
b2 = newb2
}
return ret
}
}

View File

@@ -0,0 +1,154 @@
.pragma library
/**
* @param {number} x
* @param {number} y
* @returns {Point}
*/
function createPoint(x, y) {
return new Point(x, y);
}
class Point {
/**
* @param {float} x
* @param {float} y
*/
constructor(x, y) {
this.x = x;
this.y = y;
}
/**
* @param {float} x
* @param {float} y
* @returns {Point}
*/
copy(x = this.x, y = this.y) {
return new Point(x, y);
}
/**
* @returns {float}
*/
getDistance() {
return Math.sqrt(this.x * this.x + this.y * this.y);
}
/**
* @returns {float}
*/
getDistanceSquared() {
return this.x * this.x + this.y * this.y;
}
/**
* @param {Point} other
* @returns {float}
*/
dotProduct(other) {
return this.x * other.x + this.y * other.y;
}
/**
* @param {float} otherX
* @param {float} otherY
* @returns {float}
*/
dotProductScalar(otherX, otherY) {
return this.x * otherX + this.y * otherY;
}
/**
* @param {Point} other
* @returns {boolean}
*/
clockwise(other) {
return this.x * other.y - this.y * other.x > 0;
}
/**
* @returns {Point}
*/
getDirection() {
const d = this.getDistance();
return this.div(d);
}
/**
* @returns {Point}
*/
negate() {
return new Point(-this.x, -this.y);
}
/**
* @param {Point} other
* @returns {Point}
*/
minus(other) {
return new Point(this.x - other.x, this.y - other.y);
}
/**
* @param {Point} other
* @returns {Point}
*/
plus(other) {
return new Point(this.x + other.x, this.y + other.y);
}
/**
* @param {float} operand
* @returns {Point}
*/
times(operand) {
return new Point(this.x * operand, this.y * operand);
}
/**
* @param {float} operand
* @returns {Point}
*/
div(operand) {
return new Point(this.x / operand, this.y / operand);
}
/**
* @param {float} operand
* @returns {Point}
*/
rem(operand) {
return new Point(this.x % operand, this.y % operand);
}
/**
* @param {Point} start
* @param {Point} stop
* @param {float} fraction
* @returns {Point}
*/
static interpolate(start, stop, fraction) {
return new Point(
start.x + (stop.x - start.x) * fraction,
start.y + (stop.y - start.y) * fraction
);
}
/**
* @param {function(float, float): Point} f
* @returns {Point}
*/
transformed(f) {
const result = f(this.x, this.y);
return new Point(result.x, result.y);
}
/**
* @returns {Point}
*/
rotate90() {
return new Point(-this.y, this.x);
}
}

View File

@@ -0,0 +1,192 @@
.pragma library
.import "cubic.js" as Cubic
.import "point.js" as Point
.import "feature-mapping.js" as FeatureMapping
.import "utils.js" as Utils
.import "feature.js" as Feature
class MeasuredPolygon {
constructor(measurer, features, cubics, outlineProgress) {
this.measurer = measurer
this.features = features
this.outlineProgress = outlineProgress
this.cubics = []
const measuredCubics = []
let startOutlineProgress = 0
for(let i = 0; i < cubics.length; i++) {
if ((outlineProgress[i + 1] - outlineProgress[i]) > Utils.DistanceEpsilon) {
measuredCubics.push(
new MeasuredCubic(this, cubics[i], startOutlineProgress, outlineProgress[i + 1])
)
// The next measured cubic will start exactly where this one ends.
startOutlineProgress = outlineProgress[i + 1]
}
}
measuredCubics[measuredCubics.length - 1].updateProgressRange(measuredCubics[measuredCubics.length - 1].startOutlineProgress, 1)
this.cubics = measuredCubics
}
cutAndShift(cuttingPoint) {
if (cuttingPoint < Utils.DistanceEpsilon) return this
// Find the index of cubic we want to cut
const targetIndex = this.cubics.findIndex(it => cuttingPoint >= it.startOutlineProgress && cuttingPoint <= it.endOutlineProgress)
const target = this.cubics[targetIndex]
// Cut the target cubic.
// b1, b2 are two resulting cubics after cut
const { a: b1, b: b2 } = target.cutAtProgress(cuttingPoint)
// Construct the list of the cubics we need:
// * The second part of the target cubic (after the cut)
// * All cubics after the target, until the end + All cubics from the start, before the
// target cubic
// * The first part of the target cubic (before the cut)
const retCubics = [b2.cubic]
for(let i = 1; i < this.cubics.length; i++) {
retCubics.push(this.cubics[(i + targetIndex) % this.cubics.length].cubic)
}
retCubics.push(b1.cubic)
// Construct the array of outline progress.
// For example, if we have 3 cubics with outline progress [0 .. 0.3], [0.3 .. 0.8] &
// [0.8 .. 1.0], and we cut + shift at 0.6:
// 0. 0123456789
// |--|--/-|-|
// The outline progresses will start at 0 (the cutting point, that shifs to 0.0),
// then 0.8 - 0.6 = 0.2, then 1 - 0.6 = 0.4, then 0.3 - 0.6 + 1 = 0.7,
// then 1 (the cutting point again),
// all together: (0.0, 0.2, 0.4, 0.7, 1.0)
const retOutlineProgress = []
for (let i = 0; i < this.cubics.length + 2; i++) {
if (i === 0) {
retOutlineProgress.push(0)
} else if(i === this.cubics.length + 1) {
retOutlineProgress.push(1)
} else {
const cubicIndex = (targetIndex + i - 1) % this.cubics.length
retOutlineProgress.push(Utils.positiveModulo(this.cubics[cubicIndex].endOutlineProgress - cuttingPoint, 1))
}
}
// Shift the feature's outline progress too.
const newFeatures = []
for(let i = 0; i < this.features.length; i++) {
newFeatures.push(new FeatureMapping.ProgressableFeature(Utils.positiveModulo(this.features[i].progress - cuttingPoint, 1), this.features[i].feature))
}
// Filter out all empty cubics (i.e. start and end anchor are (almost) the same point.)
return new MeasuredPolygon(this.measurer, newFeatures, retCubics, retOutlineProgress)
}
static measurePolygon(measurer, polygon) {
const cubics = []
const featureToCubic = []
for (let featureIndex = 0; featureIndex < polygon.features.length; featureIndex++) {
const feature = polygon.features[featureIndex]
for (let cubicIndex = 0; cubicIndex < feature.cubics.length; cubicIndex++) {
if (feature instanceof Feature.Corner && cubicIndex == feature.cubics.length / 2) {
featureToCubic.push({ a: feature, b: cubics.length })
}
cubics.push(feature.cubics[cubicIndex])
}
}
const measures = [0] // Initialize with 0 like in Kotlin's scan
for (const cubic of cubics) {
const measurement = measurer.measureCubic(cubic)
if (measurement < 0) {
throw new Error("Measured cubic is expected to be greater or equal to zero")
}
const lastMeasure = measures[measures.length - 1]
measures.push(lastMeasure + measurement)
}
const totalMeasure = measures[measures.length - 1]
const outlineProgress = []
for (let i = 0; i < measures.length; i++) {
outlineProgress.push(measures[i] / totalMeasure)
}
const features = []
for (let i = 0; i < featureToCubic.length; i++) {
const ix = featureToCubic[i].b
features.push(
new FeatureMapping.ProgressableFeature(Utils.positiveModulo((outlineProgress[ix] + outlineProgress[ix + 1]) / 2, 1), featureToCubic[i].a))
}
return new MeasuredPolygon(measurer, features, cubics, outlineProgress)
}
}
class MeasuredCubic {
constructor(polygon, cubic, startOutlineProgress, endOutlineProgress) {
this.polygon = polygon
this.cubic = cubic
this.startOutlineProgress = startOutlineProgress
this.endOutlineProgress = endOutlineProgress
this.measuredSize = this.polygon.measurer.measureCubic(cubic)
}
updateProgressRange(
startOutlineProgress = this.startOutlineProgress,
endOutlineProgress = this.endOutlineProgress,
) {
this.startOutlineProgress = startOutlineProgress
this.endOutlineProgress = endOutlineProgress
}
cutAtProgress(cutOutlineProgress) {
const boundedCutOutlineProgress = Utils.coerceIn(cutOutlineProgress, this.startOutlineProgress, this.endOutlineProgress)
const outlineProgressSize = this.endOutlineProgress - this.startOutlineProgress
const progressFromStart = boundedCutOutlineProgress - this.startOutlineProgress
const relativeProgress = progressFromStart / outlineProgressSize
const t = this.polygon.measurer.findCubicCutPoint(this.cubic, relativeProgress * this.measuredSize)
const {a: c1, b: c2} = this.cubic.split(t)
return {
a: new MeasuredCubic(this.polygon, c1, this.startOutlineProgress, boundedCutOutlineProgress),
b: new MeasuredCubic(this.polygon, c2, boundedCutOutlineProgress, this.endOutlineProgress)
}
}
}
class LengthMeasurer {
constructor() {
this.segments = 3
}
measureCubic(c) {
return this.closestProgressTo(c, Number.POSITIVE_INFINITY).y
}
findCubicCutPoint(c, m) {
return this.closestProgressTo(c, m).x
}
closestProgressTo(cubic, threshold) {
let total = 0
let remainder = threshold
let prev = new Point.Point(cubic.anchor0X, cubic.anchor0Y)
for (let i = 1; i < this.segments; i++) {
const progress = i / this.segments
const point = cubic.pointOnCurve(progress)
const segment = point.minus(prev).getDistance()
if (segment >= remainder) {
return new Point.Point(progress - (1.0 - remainder / segment) / this.segments, threshold)
}
remainder -= segment
total += segment
prev = point
}
return new Point.Point(1.0, total)
}
}

View File

@@ -0,0 +1,229 @@
.pragma library
.import "point.js" as PointModule
.import "corner-rounding.js" as RoundingModule
.import "utils.js" as UtilsModule
.import "cubic.js" as CubicModule
var Point = PointModule.Point;
var CornerRounding = RoundingModule.CornerRounding;
var DistanceEpsilon = UtilsModule.DistanceEpsilon;
var directionVector = UtilsModule.directionVector;
var Cubic = CubicModule.Cubic;
class RoundedCorner {
/**
* @param {Point} p0
* @param {Point} p1
* @param {Point} p2
* @param {CornerRounding} [rounding=null]
*/
constructor(p0, p1, p2, rounding = null) {
this.p0 = p0;
this.p1 = p1;
this.p2 = p2;
this.rounding = rounding;
this.center = new Point(0, 0);
const v01 = p0.minus(p1);
const v21 = p2.minus(p1);
const d01 = v01.getDistance();
const d21 = v21.getDistance();
if (d01 > 0 && d21 > 0) {
this.d1 = v01.div(d01);
this.d2 = v21.div(d21);
this.cornerRadius = rounding?.radius ?? 0;
this.smoothing = rounding?.smoothing ?? 0;
// cosine of angle at p1 is dot product of unit vectors to the other two vertices
this.cosAngle = this.d1.dotProduct(this.d2);
// identity: sin^2 + cos^2 = 1
// sinAngle gives us the intersection
this.sinAngle = Math.sqrt(1 - Math.pow(this.cosAngle, 2));
// How much we need to cut, as measured on a side, to get the required radius
// calculating where the rounding circle hits the edge
// This uses the identity of tan(A/2) = sinA/(1 + cosA), where tan(A/2) = radius/cut
this.expectedRoundCut = this.sinAngle > 1e-3 ? this.cornerRadius * (this.cosAngle + 1) / this.sinAngle : 0;
} else {
// One (or both) of the sides is empty, not much we can do.
this.d1 = new Point(0, 0);
this.d2 = new Point(0, 0);
this.cornerRadius = 0;
this.smoothing = 0;
this.cosAngle = 0;
this.sinAngle = 0;
this.expectedRoundCut = 0;
}
}
get expectedCut() {
return ((1 + this.smoothing) * this.expectedRoundCut);
}
/**
* @param {float} allowedCut0
* @param {float} [allowedCut1]
* @returns {Array<Cubic>}
*/
getCubics(allowedCut0, allowedCut1 = allowedCut0) {
// We use the minimum of both cuts to determine the radius, but if there is more space
// in one side we can use it for smoothing.
const allowedCut = Math.min(allowedCut0, allowedCut1);
// Nothing to do, just use lines, or a point
if (
this.expectedRoundCut < DistanceEpsilon ||
allowedCut < DistanceEpsilon ||
this.cornerRadius < DistanceEpsilon
) {
this.center = this.p1;
return [Cubic.straightLine(this.p1.x, this.p1.y, this.p1.x, this.p1.y)];
}
// How much of the cut is required for the rounding part.
const actualRoundCut = Math.min(allowedCut, this.expectedRoundCut);
// We have two smoothing values, one for each side of the vertex
// Space is used for rounding values first. If there is space left over, then we
// apply smoothing, if it was requested
const actualSmoothing0 = this.calculateActualSmoothingValue(allowedCut0);
const actualSmoothing1 = this.calculateActualSmoothingValue(allowedCut1);
// Scale the radius if needed
const actualR = this.cornerRadius * actualRoundCut / this.expectedRoundCut;
// Distance from the corner (p1) to the center
const centerDistance = Math.sqrt(Math.pow(actualR, 2) + Math.pow(actualRoundCut, 2));
// Center of the arc we will use for rounding
this.center = this.p1.plus(this.d1.plus(this.d2).div(2).getDirection().times(centerDistance));
const circleIntersection0 = this.p1.plus(this.d1.times(actualRoundCut));
const circleIntersection2 = this.p1.plus(this.d2.times(actualRoundCut));
const flanking0 = this.computeFlankingCurve(
actualRoundCut,
actualSmoothing0,
this.p1,
this.p0,
circleIntersection0,
circleIntersection2,
this.center,
actualR
);
const flanking2 = this.computeFlankingCurve(
actualRoundCut,
actualSmoothing1,
this.p1,
this.p2,
circleIntersection2,
circleIntersection0,
this.center,
actualR
).reverse();
return [
flanking0,
Cubic.circularArc(
this.center.x,
this.center.y,
flanking0.anchor1X,
flanking0.anchor1Y,
flanking2.anchor0X,
flanking2.anchor0Y
),
flanking2
];
}
/**
* @private
* @param {float} allowedCut
* @returns {float}
*/
calculateActualSmoothingValue(allowedCut) {
if (allowedCut > this.expectedCut) {
return this.smoothing;
} else if (allowedCut > this.expectedRoundCut) {
return this.smoothing * (allowedCut - this.expectedRoundCut) / (this.expectedCut - this.expectedRoundCut);
} else {
return 0;
}
}
/**
* @private
* @param {float} actualRoundCut
* @param {float} actualSmoothingValues
* @param {Point} corner
* @param {Point} sideStart
* @param {Point} circleSegmentIntersection
* @param {Point} otherCircleSegmentIntersection
* @param {Point} circleCenter
* @param {float} actualR
* @returns {Cubic}
*/
computeFlankingCurve(
actualRoundCut,
actualSmoothingValues,
corner,
sideStart,
circleSegmentIntersection,
otherCircleSegmentIntersection,
circleCenter,
actualR
) {
// sideStart is the anchor, 'anchor' is actual control point
const sideDirection = (sideStart.minus(corner)).getDirection();
const curveStart = corner.plus(sideDirection.times(actualRoundCut * (1 + actualSmoothingValues)));
// We use an approximation to cut a part of the circle section proportional to 1 - smooth,
// When smooth = 0, we take the full section, when smooth = 1, we take nothing.
const p = Point.interpolate(
circleSegmentIntersection,
(circleSegmentIntersection.plus(otherCircleSegmentIntersection)).div(2),
actualSmoothingValues
);
// The flanking curve ends on the circle
const curveEnd = circleCenter.plus(
directionVector(p.x - circleCenter.x, p.y - circleCenter.y).times(actualR)
);
// The anchor on the circle segment side is in the intersection between the tangent to the
// circle in the circle/flanking curve boundary and the linear segment.
const circleTangent = (curveEnd.minus(circleCenter)).rotate90();
const anchorEnd = this.lineIntersection(sideStart, sideDirection, curveEnd, circleTangent) ?? circleSegmentIntersection;
// From what remains, we pick a point for the start anchor.
// 2/3 seems to come from design tools?
const anchorStart = (curveStart.plus(anchorEnd.times(2))).div(3);
return Cubic.create(curveStart, anchorStart, anchorEnd, curveEnd);
}
/**
* @private
* @param {Point} p0
* @param {Point} d0
* @param {Point} p1
* @param {Point} d1
* @returns {Point|null}
*/
lineIntersection(p0, d0, p1, d1) {
const rotatedD1 = d1.rotate90();
const den = d0.dotProduct(rotatedD1);
if (Math.abs(den) < DistanceEpsilon) return null;
const num = (p1.minus(p0)).dotProduct(rotatedD1);
// Also check the relative value. This is equivalent to abs(den/num) < DistanceEpsilon,
// but avoid doing a division
if (Math.abs(den) < DistanceEpsilon * Math.abs(num)) return null;
const k = num / den;
return p0.plus(d0.times(k));
}
}

View File

@@ -0,0 +1,343 @@
.pragma library
.import "feature.js" as Feature
.import "point.js" as Point
.import "cubic.js" as Cubic
.import "utils.js" as Utils
.import "corner-rounding.js" as CornerRounding
.import "rounded-corner.js" as RoundedCorner
class RoundedPolygon {
constructor(features, center) {
this.features = features
this.center = center
this.cubics = this.buildCubicList()
}
get centerX() {
return this.center.x
}
get centerY() {
return this.center.y
}
transformed(f) {
const center = this.center.transformed(f)
return new RoundedPolygon(this.features.map(x => x.transformed(f)), center)
}
normalized() {
const bounds = this.calculateBounds()
const width = bounds[2] - bounds[0]
const height = bounds[3] - bounds[1]
const side = Math.max(width, height)
// Center the shape if bounds are not a square
const offsetX = (side - width) / 2 - bounds[0] /* left */
const offsetY = (side - height) / 2 - bounds[1] /* top */
return this.transformed((x, y) => {
return new Point.Point((x + offsetX) / side, (y + offsetY) / side)
})
}
calculateMaxBounds(bounds = []) {
let maxDistSquared = 0
for (let i = 0; i < this.cubics.length; i++) {
const cubic = this.cubics[i]
const anchorDistance = Utils.distanceSquared(cubic.anchor0X - this.centerX, cubic.anchor0Y - this.centerY)
const middlePoint = cubic.pointOnCurve(.5)
const middleDistance = Utils.distanceSquared(middlePoint.x - this.centerX, middlePoint.y - this.centerY)
maxDistSquared = Math.max(maxDistSquared, Math.max(anchorDistance, middleDistance))
}
const distance = Math.sqrt(maxDistSquared)
bounds[0] = this.centerX - distance
bounds[1] = this.centerY - distance
bounds[2] = this.centerX + distance
bounds[3] = this.centerY + distance
return bounds
}
calculateBounds(bounds = [], approximate = true) {
let minX = Number.MAX_SAFE_INTEGER
let minY = Number.MAX_SAFE_INTEGER
let maxX = Number.MIN_SAFE_INTEGER
let maxY = Number.MIN_SAFE_INTEGER
for (let i = 0; i < this.cubics.length; i++) {
const cubic = this.cubics[i]
cubic.calculateBounds(bounds, approximate)
minX = Math.min(minX, bounds[0])
minY = Math.min(minY, bounds[1])
maxX = Math.max(maxX, bounds[2])
maxY = Math.max(maxY, bounds[3])
}
bounds[0] = minX
bounds[1] = minY
bounds[2] = maxX
bounds[3] = maxY
return bounds
}
buildCubicList() {
const result = []
// The first/last mechanism here ensures that the final anchor point in the shape
// exactly matches the first anchor point. There can be rendering artifacts introduced
// by those points being slightly off, even by much less than a pixel
let firstCubic = null
let lastCubic = null
let firstFeatureSplitStart = null
let firstFeatureSplitEnd = null
if (this.features.length > 0 && this.features[0].cubics.length == 3) {
const centerCubic = this.features[0].cubics[1]
const { a: start, b: end } = centerCubic.split(.5)
firstFeatureSplitStart = [this.features[0].cubics[0], start]
firstFeatureSplitEnd = [end, this.features[0].cubics[2]]
}
// iterating one past the features list size allows us to insert the initial split
// cubic if it exists
for (let i = 0; i <= this.features.length; i++) {
let featureCubics
if (i == 0 && firstFeatureSplitEnd != null) {
featureCubics = firstFeatureSplitEnd
} else if (i == this.features.length) {
if (firstFeatureSplitStart != null) {
featureCubics = firstFeatureSplitStart
} else {
break
}
} else {
featureCubics = this.features[i].cubics
}
for (let j = 0; j < featureCubics.length; j++) {
// Skip zero-length curves; they add nothing and can trigger rendering artifacts
const cubic = featureCubics[j]
if (!cubic.zeroLength()) {
if (lastCubic != null)
result.push(lastCubic)
lastCubic = cubic
if (firstCubic == null)
firstCubic = cubic
} else {
if (lastCubic != null) {
// Dropping several zero-ish length curves in a row can lead to
// enough discontinuity to throw an exception later, even though the
// distances are quite small. Account for that by making the last
// cubic use the latest anchor point, always.
lastCubic = new Cubic.Cubic([...lastCubic.points]) // Make a copy before mutating
lastCubic.points[6] = cubic.anchor1X
lastCubic.points[7] = cubic.anchor1Y
}
}
}
}
if (lastCubic != null && firstCubic != null) {
result.push(
new Cubic.Cubic([
lastCubic.anchor0X,
lastCubic.anchor0Y,
lastCubic.control0X,
lastCubic.control0Y,
lastCubic.control1X,
lastCubic.control1Y,
firstCubic.anchor0X,
firstCubic.anchor0Y,
])
)
} else {
// Empty / 0-sized polygon.
result.push(new Cubic.Cubic([this.centerX, this.centerY, this.centerX, this.centerY, this.centerX, this.centerY, this.centerX, this.centerY]))
}
return result
}
static calculateCenter(vertices) {
let cumulativeX = 0
let cumulativeY = 0
let index = 0
while (index < vertices.length) {
cumulativeX += vertices[index++]
cumulativeY += vertices[index++]
}
return new Point.Point(cumulativeX / (vertices.length / 2), cumulativeY / (vertices.length / 2))
}
static verticesFromNumVerts(numVertices, radius, centerX, centerY) {
const result = []
let arrayIndex = 0
for (let i = 0; i < numVertices; i++) {
const vertex = Utils.radialToCartesian(radius, (Math.PI / numVertices * 2 * i)).plus(new Point.Point(centerX, centerY))
result[arrayIndex++] = vertex.x
result[arrayIndex++] = vertex.y
}
return result
}
static fromNumVertices(numVertices, radius = 1, centerX = 0, centerY = 0, rounding = CornerRounding.Unrounded, perVertexRounding = null) {
return RoundedPolygon.fromVertices(this.verticesFromNumVerts(numVertices, radius, centerX, centerY), rounding, perVertexRounding, centerX, centerY)
}
static fromVertices(vertices, rounding = CornerRounding.Unrounded, perVertexRounding = null, centerX = Number.MIN_SAFE_INTEGER, centerY = Number.MAX_SAFE_INTEGER) {
const corners = []
const n = vertices.length / 2
const roundedCorners = []
for (let i = 0; i < n; i++) {
const vtxRounding = perVertexRounding?.[i] ?? rounding
const prevIndex = ((i + n - 1) % n) * 2
const nextIndex = ((i + 1) % n) * 2
roundedCorners.push(
new RoundedCorner.RoundedCorner(
new Point.Point(vertices[prevIndex], vertices[prevIndex + 1]),
new Point.Point(vertices[i * 2], vertices[i * 2 + 1]),
new Point.Point(vertices[nextIndex], vertices[nextIndex + 1]),
vtxRounding
)
)
}
// For each side, check if we have enough space to do the cuts needed, and if not split
// the available space, first for round cuts, then for smoothing if there is space left.
// Each element in this list is a pair, that represent how much we can do of the cut for
// the given side (side i goes from corner i to corner i+1), the elements of the pair are:
// first is how much we can use of expectedRoundCut, second how much of expectedCut
const cutAdjusts = Array.from({ length: n }).map((_, ix) => {
const expectedRoundCut = roundedCorners[ix].expectedRoundCut + roundedCorners[(ix + 1) % n].expectedRoundCut
const expectedCut = roundedCorners[ix].expectedCut + roundedCorners[(ix + 1) % n].expectedCut
const vtxX = vertices[ix * 2]
const vtxY = vertices[ix * 2 + 1]
const nextVtxX = vertices[((ix + 1) % n) * 2]
const nextVtxY = vertices[((ix + 1) % n) * 2 + 1]
const sideSize = Utils.distance(vtxX - nextVtxX, vtxY - nextVtxY)
// Check expectedRoundCut first, and ensure we fulfill rounding needs first for
// both corners before using space for smoothing
if (expectedRoundCut > sideSize) {
// Not enough room for fully rounding, see how much we can actually do.
return { a: sideSize / expectedRoundCut, b: 0 }
} else if (expectedCut > sideSize) {
// We can do full rounding, but not full smoothing.
return { a: 1, b: (sideSize - expectedRoundCut) / (expectedCut - expectedRoundCut) }
} else {
// There is enough room for rounding & smoothing.
return { a: 1, b: 1 }
}
})
// Create and store list of beziers for each [potentially] rounded corner
for (let i = 0; i < n; i++) {
// allowedCuts[0] is for the side from the previous corner to this one,
// allowedCuts[1] is for the side from this corner to the next one.
const allowedCuts = []
for(const delta of [0, 1]) {
const { a: roundCutRatio, b: cutRatio } = cutAdjusts[(i + n - 1 + delta) % n]
allowedCuts.push(
roundedCorners[i].expectedRoundCut * roundCutRatio +
(roundedCorners[i].expectedCut - roundedCorners[i].expectedRoundCut) * cutRatio
)
}
corners.push(
roundedCorners[i].getCubics(allowedCuts[0], allowedCuts[1])
)
}
const tempFeatures = []
for (let i = 0; i < n; i++) {
// Note that these indices are for pairs of values (points), they need to be
// doubled to access the xy values in the vertices float array
const prevVtxIndex = (i + n - 1) % n
const nextVtxIndex = (i + 1) % n
const currVertex = new Point.Point(vertices[i * 2], vertices[i * 2 + 1])
const prevVertex = new Point.Point(vertices[prevVtxIndex * 2], vertices[prevVtxIndex * 2 + 1])
const nextVertex = new Point.Point(vertices[nextVtxIndex * 2], vertices[nextVtxIndex * 2 + 1])
const cnvx = Utils.convex(prevVertex, currVertex, nextVertex)
tempFeatures.push(new Feature.Corner(corners[i], cnvx))
tempFeatures.push(
new Feature.Edge([Cubic.Cubic.straightLine(
corners[i][corners[i].length - 1].anchor1X,
corners[i][corners[i].length - 1].anchor1Y,
corners[(i + 1) % n][0].anchor0X,
corners[(i + 1) % n][0].anchor0Y,
)])
)
}
let center
if (centerX == Number.MIN_SAFE_INTEGER || centerY == Number.MIN_SAFE_INTEGER) {
center = RoundedPolygon.calculateCenter(vertices)
} else {
center = new Point.Point(centerX, centerY)
}
return RoundedPolygon.fromFeatures(tempFeatures, center.x, center.y)
}
static fromFeatures(features, centerX, centerY) {
const vertices = []
for (const feature of features) {
for (const cubic of feature.cubics) {
vertices.push(cubic.anchor0X)
vertices.push(cubic.anchor0Y)
}
}
if (Number.isNaN(centerX)) {
centerX = this.calculateCenter(vertices).x
}
if (Number.isNaN(centerY)) {
centerY = this.calculateCenter(vertices).y
}
return new RoundedPolygon(features, new Point.Point(centerX, centerY))
}
static circle(numVertices = 8, radius = 1, centerX = 0, centerY = 0) {
// Half of the angle between two adjacent vertices on the polygon
const theta = Math.PI / numVertices
// Radius of the underlying RoundedPolygon object given the desired radius of the circle
const polygonRadius = radius / Math.cos(theta)
return RoundedPolygon.fromNumVertices(
numVertices,
polygonRadius,
centerX,
centerY,
new CornerRounding.CornerRounding(radius)
)
}
static rectangle(width, height, rounding = CornerRounding.Unrounded, perVertexRounding = null, centerX = 0, centerY = 0) {
const left = centerX - width / 2
const top = centerY - height / 2
const right = centerX + width / 2
const bottom = centerY + height / 2
return RoundedPolygon.fromVertices([right, bottom, left, bottom, left, top, right, top], rounding, perVertexRounding, centerX, centerY)
}
static star(numVerticesPerRadius, radius = 1, innerRadius = .5, rounding = CornerRounding.Unrounded, innerRounding = null, perVertexRounding = null, centerX = 0, centerY = 0) {
let pvRounding = perVertexRounding
// If no per-vertex rounding supplied and caller asked for inner rounding,
// create per-vertex rounding list based on supplied outer/inner rounding parameters
if (pvRounding == null && innerRounding != null) {
pvRounding = Array.from({ length: numVerticesPerRadius * 2 }).flatMap(() => [rounding, innerRounding])
}
return RoundedPolygon.fromVertices(RoundedPolygon.starVerticesFromNumVerts(numVerticesPerRadius, radius, innerRadius, centerX, centerY), rounding, perVertexRounding, centerX, centerY)
}
static starVerticesFromNumVerts(numVerticesPerRadius, radius, innerRadius, centerX, centerY) {
const result = []
let arrayIndex = 0
for (let i = 0; i < numVerticesPerRadius; i++) {
let vertex = Utils.radialToCartesian(radius, (Math.PI / numVerticesPerRadius * 2 * i))
result[arrayIndex++] = vertex.x + centerX
result[arrayIndex++] = vertex.y + centerY
vertex = Utils.radialToCartesian(innerRadius, (Math.PI / numVerticesPerRadius * (2 * i + 1)))
result[arrayIndex++] = vertex.x + centerX
result[arrayIndex++] = vertex.y + centerY
}
return result
}
}

View File

@@ -0,0 +1,94 @@
.pragma library
.import "point.js" as PointModule
var Point = PointModule.Point;
var DistanceEpsilon = 1e-4;
var AngleEpsilon = 1e-6;
/**
* @param {Point} previous
* @param {Point} current
* @param {Point} next
* @returns {boolean}
*/
function convex(previous, current, next) {
return (current.minus(previous)).clockwise(next.minus(current));
}
/**
* @param {float} start
* @param {float} stop
* @param {float} fraction
* @returns {float}
*/
function interpolate(start, stop, fraction) {
return (1 - fraction) * start + fraction * stop;
}
/**
* @param {float} x
* @param {float} y
* @returns {Point}
*/
function directionVector(x, y) {
const d = distance(x, y);
return new Point(x / d, y / d);
}
/**
* @param {float} x
* @param {float} y
* @returns {float}
*/
function distance(x, y) {
return Math.sqrt(x * x + y * y);
}
/**
* @param {float} x
* @param {float} y
* @returns {float}
*/
function distanceSquared(x, y) {
return x * x + y * y;
}
/**
* @param {float} radius
* @param {float} angleRadians
* @param {Point} [center]
* @returns {Point}
*/
function radialToCartesian(radius, angleRadians, center = new Point(0, 0)) {
return new Point(Math.cos(angleRadians), Math.sin(angleRadians))
.times(radius)
.plus(center);
}
/**
* @param {float} value
* @param {float|object} min
* @param {float} [max]
* @returns {float}
*/
function coerceIn(value, min, max) {
if (max === undefined) {
if (typeof min === 'object' && 'start' in min && 'endInclusive' in min) {
return Math.max(min.start, Math.min(min.endInclusive, value));
}
throw new Error("Invalid arguments for coerceIn");
}
const [actualMin, actualMax] = min <= max ? [min, max] : [max, min];
return Math.max(actualMin, Math.min(actualMax, value));
}
/**
* @param {float} value
* @param {float} mod
* @returns {float}
*/
function positiveModulo(value, mod) {
return ((value % mod) + mod) % mod;
}