mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2025-08-04 13:30:48 +00:00
Add rotation to Repeat node
This commit is contained in:
parent
5f4960db9b
commit
72ba4ddfe4
7 changed files with 506 additions and 539 deletions
843
Cargo.lock
generated
843
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -2628,11 +2628,12 @@ fn static_nodes() -> Vec<DocumentNodeDefinition> {
|
|||
DocumentNodeDefinition {
|
||||
name: "Repeat",
|
||||
category: "Vector",
|
||||
implementation: DocumentNodeImplementation::proto("graphene_core::vector::RepeatNode<_, _>"),
|
||||
implementation: DocumentNodeImplementation::proto("graphene_core::vector::RepeatNode<_, _, _>"),
|
||||
inputs: vec![
|
||||
DocumentInputType::value("Instance", TaggedValue::VectorData(graphene_core::vector::VectorData::empty()), true),
|
||||
DocumentInputType::value("Direction", TaggedValue::DVec2((100., 0.).into()), false),
|
||||
DocumentInputType::value("Count", TaggedValue::U32(10), false),
|
||||
DocumentInputType::value("Direction", TaggedValue::DVec2((100., 100.).into()), false),
|
||||
DocumentInputType::value("Angle", TaggedValue::F64(0.), false),
|
||||
DocumentInputType::value("Instances", TaggedValue::U32(5), false),
|
||||
],
|
||||
outputs: vec![DocumentOutputType::new("Vector", FrontendGraphDataType::Subpath)],
|
||||
properties: node_properties::repeat_properties,
|
||||
|
@ -2646,7 +2647,7 @@ fn static_nodes() -> Vec<DocumentNodeDefinition> {
|
|||
DocumentInputType::value("Instance", TaggedValue::VectorData(graphene_core::vector::VectorData::empty()), true),
|
||||
DocumentInputType::value("Angle Offset", TaggedValue::F64(0.), false),
|
||||
DocumentInputType::value("Radius", TaggedValue::F64(5.), false),
|
||||
DocumentInputType::value("Count", TaggedValue::U32(10), false),
|
||||
DocumentInputType::value("Instances", TaggedValue::U32(5), false),
|
||||
],
|
||||
outputs: vec![DocumentOutputType::new("Vector", FrontendGraphDataType::Subpath)],
|
||||
properties: node_properties::circular_repeat_properties,
|
||||
|
|
|
@ -2289,17 +2289,22 @@ pub fn stroke_properties(document_node: &DocumentNode, node_id: NodeId, _context
|
|||
|
||||
pub fn repeat_properties(document_node: &DocumentNode, node_id: NodeId, _context: &mut NodePropertiesContext) -> Vec<LayoutGroup> {
|
||||
let direction = vec2_widget(document_node, node_id, 1, "Direction", "X", "Y", " px", None, add_blank_assist);
|
||||
let count = number_widget(document_node, node_id, 2, "Count", NumberInput::default().min(1.), true);
|
||||
let angle = number_widget(document_node, node_id, 2, "Angle", NumberInput::default().unit("°"), true);
|
||||
let instances = number_widget(document_node, node_id, 3, "Instances", NumberInput::default().min(1.).is_integer(true), true);
|
||||
|
||||
vec![direction, LayoutGroup::Row { widgets: count }]
|
||||
vec![direction, LayoutGroup::Row { widgets: angle }, LayoutGroup::Row { widgets: instances }]
|
||||
}
|
||||
|
||||
pub fn circular_repeat_properties(document_node: &DocumentNode, node_id: NodeId, _context: &mut NodePropertiesContext) -> Vec<LayoutGroup> {
|
||||
let angle_offset = number_widget(document_node, node_id, 1, "Angle Offset", NumberInput::default().unit("°"), true);
|
||||
let radius = number_widget(document_node, node_id, 2, "Radius", NumberInput::default(), true); // TODO: What units?
|
||||
let count = number_widget(document_node, node_id, 3, "Count", NumberInput::default().min(1.), true);
|
||||
let instances = number_widget(document_node, node_id, 3, "Instances", NumberInput::default().min(1.).is_integer(true), true);
|
||||
|
||||
vec![LayoutGroup::Row { widgets: angle_offset }, LayoutGroup::Row { widgets: radius }, LayoutGroup::Row { widgets: count }]
|
||||
vec![
|
||||
LayoutGroup::Row { widgets: angle_offset },
|
||||
LayoutGroup::Row { widgets: radius },
|
||||
LayoutGroup::Row { widgets: instances },
|
||||
]
|
||||
}
|
||||
|
||||
pub fn copy_to_points_properties(document_node: &DocumentNode, node_id: NodeId, _context: &mut NodePropertiesContext) -> Vec<LayoutGroup> {
|
||||
|
|
|
@ -116,7 +116,7 @@ impl Fsm for FillToolFsmState {
|
|||
let hint_data = match self {
|
||||
FillToolFsmState::Ready => HintData(vec![HintGroup(vec![
|
||||
HintInfo::mouse(MouseMotion::Lmb, "Fill with Primary"),
|
||||
HintInfo::keys_and_mouse([Key::Shift], MouseMotion::Lmb, "Fill with Secondary"),
|
||||
HintInfo::keys([Key::Shift], "Fill with Secondary").prepend_plus(),
|
||||
])]),
|
||||
FillToolFsmState::Filling => HintData(vec![HintGroup(vec![HintInfo::mouse(MouseMotion::Rmb, ""), HintInfo::keys([Key::Escape], "Cancel").prepend_slash()])]),
|
||||
};
|
||||
|
|
|
@ -58,8 +58,8 @@
|
|||
$: gridSpacing = calculateGridSpacing(transform.scale);
|
||||
$: dotRadius = 1 + Math.floor(transform.scale - 0.5 + 0.001) / 2;
|
||||
$: nodeCategories = buildNodeCategories($nodeGraph.nodeTypes, searchTerm);
|
||||
$: nodeListX = ((nodeListLocation?.x || 0) * GRID_SIZE + transform.x) * transform.scale;
|
||||
$: nodeListY = ((nodeListLocation?.y || 0) * GRID_SIZE + transform.y) * transform.scale;
|
||||
$: nodeListX = ((nodeListLocation?.x || 0) + transform.x) * transform.scale;
|
||||
$: nodeListY = ((nodeListLocation?.y || 0) + transform.y) * transform.scale;
|
||||
|
||||
let appearAboveMouse = false;
|
||||
let appearRightOfMouse = false;
|
||||
|
@ -69,8 +69,8 @@
|
|||
if (!bounds) return;
|
||||
const { width, height } = bounds;
|
||||
|
||||
appearRightOfMouse = nodeListX > width - ADD_NODE_MENU_WIDTH / 2;
|
||||
appearAboveMouse = nodeListY > height - ADD_NODE_MENU_HEIGHT / 2;
|
||||
appearRightOfMouse = nodeListX > width - ADD_NODE_MENU_WIDTH;
|
||||
appearAboveMouse = nodeListY > height - ADD_NODE_MENU_HEIGHT;
|
||||
})();
|
||||
|
||||
$: linkPathInProgress = createLinkPathInProgress(linkInProgressFromConnector, linkInProgressToConnector);
|
||||
|
@ -320,8 +320,8 @@
|
|||
|
||||
function loadNodeList(e: PointerEvent, graphBounds: DOMRect) {
|
||||
nodeListLocation = {
|
||||
x: Math.round(((e.clientX - graphBounds.x) / transform.scale - transform.x) / GRID_SIZE),
|
||||
y: Math.round(((e.clientY - graphBounds.y) / transform.scale - transform.y) / GRID_SIZE),
|
||||
x: (e.clientX - graphBounds.x) / transform.scale - transform.x,
|
||||
y: (e.clientY - graphBounds.y) / transform.scale - transform.y,
|
||||
};
|
||||
|
||||
// Find actual relevant child and focus it (setTimeout is required to actually focus the input element)
|
||||
|
@ -639,10 +639,7 @@
|
|||
if (!nodeListLocation) return;
|
||||
let nodeListLocation2: { x: number; y: number } = nodeListLocation;
|
||||
|
||||
linkInProgressToConnector = new DOMRect(
|
||||
(nodeListLocation2.x * GRID_SIZE + transform.x) * transform.scale + graphBounds.x,
|
||||
(nodeListLocation2.y * GRID_SIZE + transform.y) * transform.scale + graphBounds.y,
|
||||
);
|
||||
linkInProgressToConnector = new DOMRect((nodeListLocation2.x + transform.x) * transform.scale + graphBounds.x, (nodeListLocation2.y + transform.y) * transform.scale + graphBounds.y);
|
||||
|
||||
return;
|
||||
} else if (draggingNodes) {
|
||||
|
@ -671,7 +668,9 @@
|
|||
if (!nodeListLocation) return;
|
||||
|
||||
const inputNodeConnectionIndex = 0;
|
||||
const inputConnectedNodeID = editor.instance.createNode(nodeType, nodeListLocation.x, nodeListLocation.y - 1);
|
||||
const x = Math.round(nodeListLocation.x / GRID_SIZE);
|
||||
const y = Math.round(nodeListLocation.y / GRID_SIZE) - 1;
|
||||
const inputConnectedNodeID = editor.instance.createNode(nodeType, x, y);
|
||||
nodeListLocation = undefined;
|
||||
|
||||
if (!linkInProgressFromConnector) return;
|
||||
|
@ -751,18 +750,18 @@
|
|||
class="node-list"
|
||||
data-node-list
|
||||
styles={{
|
||||
transform: `translate(${appearRightOfMouse ? -100 : 0}%, ${appearAboveMouse ? -100 : 0}%)`,
|
||||
left: `${nodeListX}px`,
|
||||
top: `${nodeListY}px`,
|
||||
transform: `translate(${appearRightOfMouse ? -100 : 0}%, ${appearAboveMouse ? -100 : 0}%)`,
|
||||
width: `${ADD_NODE_MENU_WIDTH}px`,
|
||||
height: `${ADD_NODE_MENU_HEIGHT}px`,
|
||||
}}
|
||||
>
|
||||
<TextInput placeholder="Search Nodes..." value={searchTerm} on:value={({ detail }) => (searchTerm = detail)} bind:this={nodeSearchInput} />
|
||||
<div class="list-nodes" style={`height: ${ADD_NODE_MENU_HEIGHT}px;`} on:wheel|passive|stopPropagation>
|
||||
<div class="list-results" on:wheel|passive|stopPropagation>
|
||||
{#each nodeCategories as nodeCategory}
|
||||
<details style="display: flex; flex-direction: column;" open={nodeCategory[1].open}>
|
||||
<details open={nodeCategory[1].open}>
|
||||
<summary>
|
||||
<IconLabel icon="DropdownArrow" />
|
||||
<TextLabel>{nodeCategory[0]}</TextLabel>
|
||||
</summary>
|
||||
{#each nodeCategory[1].nodes as nodeType}
|
||||
|
@ -770,7 +769,7 @@
|
|||
{/each}
|
||||
</details>
|
||||
{:else}
|
||||
<div style="margin-right: 4px;"><TextLabel>No search results</TextLabel></div>
|
||||
<TextLabel>No search results</TextLabel>
|
||||
{/each}
|
||||
</div>
|
||||
</LayoutCol>
|
||||
|
@ -1081,47 +1080,59 @@
|
|||
.node-list {
|
||||
width: max-content;
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
padding: 5px;
|
||||
z-index: 3;
|
||||
background-color: var(--color-3-darkgray);
|
||||
|
||||
.text-button {
|
||||
width: 100%;
|
||||
.text-input {
|
||||
flex: 0 0 auto;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.list-nodes {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
.list-results {
|
||||
overflow-y: auto;
|
||||
flex: 1 1 auto;
|
||||
|
||||
details {
|
||||
margin-right: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
details {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
summary {
|
||||
list-style-type: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
&[open] summary .text-label::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
span {
|
||||
white-space: break-spaces;
|
||||
summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
|
||||
.text-label {
|
||||
padding-left: 16px;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
margin: auto;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--icon-expand-collapse-arrow);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.text-button {
|
||||
width: 100%;
|
||||
margin: 4px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
details summary svg {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
details[open] summary svg {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
.text-button + .text-button {
|
||||
display: block;
|
||||
margin-left: 0;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.wires {
|
||||
|
|
|
@ -79,17 +79,34 @@ fn set_vector_data_stroke(
|
|||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct RepeatNode<Direction, Count> {
|
||||
pub struct RepeatNode<Direction, Angle, Instances> {
|
||||
direction: Direction,
|
||||
count: Count,
|
||||
angle: Angle,
|
||||
instances: Instances,
|
||||
}
|
||||
|
||||
#[node_macro::node_fn(RepeatNode)]
|
||||
fn repeat_vector_data(vector_data: VectorData, direction: DVec2, count: u32) -> VectorData {
|
||||
fn repeat_vector_data(vector_data: VectorData, direction: DVec2, angle: f64, instances: u32) -> VectorData {
|
||||
let angle = angle.to_radians();
|
||||
let instances = instances.max(1);
|
||||
let total = (instances - 1) as f64;
|
||||
|
||||
if instances == 1 {
|
||||
return vector_data;
|
||||
}
|
||||
|
||||
// Repeat the vector data
|
||||
let mut result = VectorData::empty();
|
||||
for i in 0..count {
|
||||
let transform = DAffine2::from_translation(direction * i as f64);
|
||||
|
||||
let Some(bounding_box) = vector_data.bounding_box() else { return vector_data };
|
||||
let center = (bounding_box[0] + bounding_box[1]) / 2.;
|
||||
|
||||
for i in 0..instances {
|
||||
let translation = i as f64 * direction / total;
|
||||
let angle = i as f64 * angle / total;
|
||||
|
||||
let transform = DAffine2::from_translation(center) * DAffine2::from_angle(angle) * DAffine2::from_translation(translation) * DAffine2::from_translation(-center);
|
||||
|
||||
result.concat(&vector_data, transform);
|
||||
}
|
||||
|
||||
|
@ -97,14 +114,20 @@ fn repeat_vector_data(vector_data: VectorData, direction: DVec2, count: u32) ->
|
|||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct CircularRepeatNode<AngleOffset, Radius, Count> {
|
||||
pub struct CircularRepeatNode<AngleOffset, Radius, Instances> {
|
||||
angle_offset: AngleOffset,
|
||||
radius: Radius,
|
||||
count: Count,
|
||||
instances: Instances,
|
||||
}
|
||||
|
||||
#[node_macro::node_fn(CircularRepeatNode)]
|
||||
fn circular_repeat_vector_data(vector_data: VectorData, angle_offset: f64, radius: f64, count: u32) -> VectorData {
|
||||
fn circular_repeat_vector_data(vector_data: VectorData, angle_offset: f64, radius: f64, instances: u32) -> VectorData {
|
||||
let instances = instances.max(1);
|
||||
|
||||
if instances == 1 {
|
||||
return vector_data;
|
||||
}
|
||||
|
||||
let mut result = VectorData::empty();
|
||||
|
||||
let Some(bounding_box) = vector_data.bounding_box() else { return vector_data };
|
||||
|
@ -112,8 +135,8 @@ fn circular_repeat_vector_data(vector_data: VectorData, angle_offset: f64, radiu
|
|||
|
||||
let base_transform = DVec2::new(0., radius) - center;
|
||||
|
||||
for i in 0..count {
|
||||
let angle = (2. * std::f64::consts::PI / count as f64) * i as f64 + angle_offset.to_radians();
|
||||
for i in 0..instances {
|
||||
let angle = (std::f64::consts::TAU / instances as f64) * i as f64 + angle_offset.to_radians();
|
||||
let rotation = DAffine2::from_angle(angle);
|
||||
let transform = DAffine2::from_translation(center) * rotation * DAffine2::from_translation(base_transform);
|
||||
result.concat(&vector_data, transform);
|
||||
|
@ -533,27 +556,31 @@ mod test {
|
|||
#[test]
|
||||
fn repeat() {
|
||||
let direction = DVec2::X * 1.5;
|
||||
let instances = 3;
|
||||
let repeated = RepeatNode {
|
||||
direction: ClonedNode::new(direction),
|
||||
count: ClonedNode::new(3),
|
||||
angle: ClonedNode::new(0.),
|
||||
instances: ClonedNode::new(instances),
|
||||
}
|
||||
.eval(VectorData::from_subpath(Subpath::new_rect(DVec2::ZERO, DVec2::ONE)));
|
||||
assert_eq!(repeated.region_bezier_paths().count(), 3);
|
||||
for (index, (_, subpath)) in repeated.region_bezier_paths().enumerate() {
|
||||
assert_eq!(subpath.manipulator_groups()[0].anchor, direction * index as f64);
|
||||
assert!((subpath.manipulator_groups()[0].anchor - direction * index as f64 / (instances - 1) as f64).length() < 1e-5);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn repeat_transform_position() {
|
||||
let direction = DVec2::new(12., 10.);
|
||||
let instances = 8;
|
||||
let repeated = RepeatNode {
|
||||
direction: ClonedNode::new(direction),
|
||||
count: ClonedNode::new(8),
|
||||
angle: ClonedNode::new(0.),
|
||||
instances: ClonedNode::new(instances),
|
||||
}
|
||||
.eval(VectorData::from_subpath(Subpath::new_rect(DVec2::ZERO, DVec2::ONE)));
|
||||
assert_eq!(repeated.region_bezier_paths().count(), 8);
|
||||
for (index, (_, subpath)) in repeated.region_bezier_paths().enumerate() {
|
||||
assert_eq!(subpath.manipulator_groups()[0].anchor, direction * index as f64);
|
||||
assert!((subpath.manipulator_groups()[0].anchor - direction * index as f64 / (instances - 1) as f64).length() < 1e-5);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
|
@ -561,29 +588,29 @@ mod test {
|
|||
let repeated = CircularRepeatNode {
|
||||
angle_offset: ClonedNode::new(45.),
|
||||
radius: ClonedNode::new(4.),
|
||||
count: ClonedNode::new(8),
|
||||
instances: ClonedNode::new(8),
|
||||
}
|
||||
.eval(VectorData::from_subpath(Subpath::new_rect(DVec2::NEG_ONE, DVec2::ONE)));
|
||||
assert_eq!(repeated.region_bezier_paths().count(), 8);
|
||||
for (index, (_, subpath)) in repeated.region_bezier_paths().enumerate() {
|
||||
let expected_angle = (index as f64 + 1.) * 45.;
|
||||
let centre = (subpath.manipulator_groups()[0].anchor + subpath.manipulator_groups()[2].anchor) / 2.;
|
||||
let actual_angle = DVec2::Y.angle_between(centre).to_degrees();
|
||||
let center = (subpath.manipulator_groups()[0].anchor + subpath.manipulator_groups()[2].anchor) / 2.;
|
||||
let actual_angle = DVec2::Y.angle_between(center).to_degrees();
|
||||
assert!((actual_angle - expected_angle).abs() % 360. < 1e-5);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn bounding_box() {
|
||||
let bouding_box = BoundingBoxNode.eval(VectorData::from_subpath(Subpath::new_rect(DVec2::NEG_ONE, DVec2::ONE)));
|
||||
assert_eq!(bouding_box.region_bezier_paths().count(), 1);
|
||||
let subpath = bouding_box.region_bezier_paths().next().unwrap().1;
|
||||
let bounding_box = BoundingBoxNode.eval(VectorData::from_subpath(Subpath::new_rect(DVec2::NEG_ONE, DVec2::ONE)));
|
||||
assert_eq!(bounding_box.region_bezier_paths().count(), 1);
|
||||
let subpath = bounding_box.region_bezier_paths().next().unwrap().1;
|
||||
assert_eq!(&subpath.anchors()[..4], &[DVec2::NEG_ONE, DVec2::new(1., -1.), DVec2::ONE, DVec2::new(-1., 1.),]);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn copy_to_points() {
|
||||
let points = VectorData::from_subpath(Subpath::new_rect(DVec2::NEG_ONE * 10., DVec2::ONE * 10.));
|
||||
let expected_points = points.point_domain.positions().to_vec();
|
||||
let bouding_box = CopyToPoints {
|
||||
let bounding_box = CopyToPoints {
|
||||
points: CullNode::new(FutureWrapperNode(ClonedNode(points))),
|
||||
instance: CullNode::new(FutureWrapperNode(ClonedNode(VectorData::from_subpath(Subpath::new_rect(DVec2::NEG_ONE, DVec2::ONE))))),
|
||||
random_scale_min: FutureWrapperNode(ClonedNode(1.)),
|
||||
|
@ -593,8 +620,8 @@ mod test {
|
|||
}
|
||||
.eval(Footprint::default())
|
||||
.await;
|
||||
assert_eq!(bouding_box.region_bezier_paths().count(), expected_points.len());
|
||||
for (index, (_, subpath)) in bouding_box.region_bezier_paths().enumerate() {
|
||||
assert_eq!(bounding_box.region_bezier_paths().count(), expected_points.len());
|
||||
for (index, (_, subpath)) in bounding_box.region_bezier_paths().enumerate() {
|
||||
let offset = expected_points[index];
|
||||
assert_eq!(
|
||||
&subpath.anchors()[..4],
|
||||
|
|
|
@ -701,7 +701,7 @@ fn node_registry() -> HashMap<ProtoNodeIdentifier, HashMap<NodeIOTypes, NodeCons
|
|||
register_node!(graphene_core::transform::SetTransformNode<_>, input: ImageFrame<Color>, params: [DAffine2]),
|
||||
register_node!(graphene_core::vector::SetFillNode<_, _, _, _, _, _, _>, input: VectorData, params: [graphene_core::vector::style::FillType, Option<graphene_core::Color>, graphene_core::vector::style::GradientType, DVec2, DVec2, DAffine2, Vec<(f64, graphene_core::Color)>]),
|
||||
register_node!(graphene_core::vector::SetStrokeNode<_, _, _, _, _, _, _>, input: VectorData, params: [Option<graphene_core::Color>, f64, Vec<f64>, f64, graphene_core::vector::style::LineCap, graphene_core::vector::style::LineJoin, f64]),
|
||||
register_node!(graphene_core::vector::RepeatNode<_, _>, input: VectorData, params: [DVec2, u32]),
|
||||
register_node!(graphene_core::vector::RepeatNode<_, _, _>, input: VectorData, params: [DVec2, f64, u32]),
|
||||
register_node!(graphene_core::vector::BoundingBoxNode, input: VectorData, params: []),
|
||||
register_node!(graphene_core::vector::SolidifyStrokeNode, input: VectorData, params: []),
|
||||
register_node!(graphene_core::vector::CircularRepeatNode<_, _, _>, input: VectorData, params: [f64, f64, u32]),
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue