UNPKG

22.4 kBPlain TextView Raw
1/*
2* Copyright (C) 1998-2021 by Northwoods Software Corporation. All Rights Reserved.
3*/
4
5/*
6* This is an extension and not part of the main GoJS library.
7* Note that the API for this class may change with any version, even point releases.
8* If you intend to use an extension in production, you should copy the code to your own source directory.
9* Extensions can be found in the GoJS kit under the extensions or extensionsTS folders.
10* See the Extensions intro page (https://gojs.net/latest/intro/extensions.html) for more information.
11*/
12
13import * as go from '../release/go-module.js';
14
15/**
16 * The GeometryReshapingTool class allows for a Shape's Geometry to be modified by the user
17 * via the dragging of tool handles.
18 * This does not handle Links, whose routes should be reshaped by the LinkReshapingTool.
19 * The {@link #reshapeObjectName} needs to identify the named {@link Shape} within the
20 * selected {@link Part}.
21 * If the shape cannot be found or if its {@link Shape#geometry} is not of type {@link Geometry.Path},
22 * this will not show any GeometryReshaping {@link Adornment}.
23 * At the current time this tool does not support adding or removing {@link PathSegment}s to the Geometry.
24 *
25 * If you want to experiment with this extension, try the <a href="../../extensionsJSM/GeometryReshaping.html">Geometry Reshaping</a> sample.
26 * @category Tool Extension
27 */
28export class GeometryReshapingTool extends go.Tool {
29
30 private _handleArchetype: go.GraphObject;
31 private _midHandleArchetype: go.GraphObject;
32 private _isResegmenting: boolean;
33 private _resegmentingDistance: number;
34 private _reshapeObjectName: string; // ??? can't add Part.reshapeObjectName property
35 // there's no Part.reshapeAdornmentTemplate either
36
37 // internal state
38 private _handle: go.GraphObject | null = null;
39 private _adornedShape: go.Shape | null = null;
40 private _originalGeometry: go.Geometry | null = null; // in case the tool is cancelled and the UndoManager is not enabled
41
42 /**
43 * Constructs a GeometryReshapingTool and sets the handle and name of the tool.
44 */
45 constructor() {
46 super();
47 this.name = 'GeometryReshaping';
48
49 let h: go.Shape = new go.Shape();
50 h.figure = 'Diamond';
51 h.desiredSize = new go.Size(8, 8);
52 h.fill = 'lightblue';
53 h.stroke = 'dodgerblue';
54 h.cursor = 'move';
55 this._handleArchetype = h;
56
57 h = new go.Shape();
58 h.figure = 'Circle';
59 h.desiredSize = new go.Size(7, 7);
60 h.fill = 'lightblue';
61 h.stroke = 'dodgerblue';
62 h.cursor = 'move';
63 this._midHandleArchetype = h;
64
65 this._isResegmenting = false;
66 this._resegmentingDistance = 3;
67
68 this._reshapeObjectName = 'SHAPE';
69 }
70
71 /**
72 * A small GraphObject used as a reshape handle for each segment.
73 * The default GraphObject is a small blue diamond.
74 */
75 get handleArchetype(): go.GraphObject { return this._handleArchetype; }
76 set handleArchetype(value: go.GraphObject) { this._handleArchetype = value; }
77
78 /**
79 * A small GraphObject used as a reshape handle at the middle of each segment for inserting a new segment.
80 * The default GraphObject is a small blue circle.
81 */
82 get midHandleArchetype(): go.GraphObject { return this._midHandleArchetype; }
83 set midHandleArchetype(value: go.GraphObject) { this._midHandleArchetype = value; }
84
85 /**
86 * Gets or sets whether this tool supports the user's addition or removal of segments in the geometry.
87 * The default value is false.
88 * When the value is true, copies of the {@link #midHandleArchetype} will appear in the middle of each segment.
89 * At the current time, resegmenting is limited to straight segments, not curved ones.
90 */
91 get isResegmenting(): boolean { return this._isResegmenting; }
92 set isResegmenting(val: boolean) { this._isResegmenting = val; }
93
94 /**
95 * The maximum distance at which a resegmenting handle being positioned on a straight line
96 * between the adjacent points will cause one of the segments to be removed from the geometry.
97 * The default value is 3.
98 */
99 get resegmentingDistance(): number { return this._resegmentingDistance; }
100 set resegmentingDistance(val: number) { this._resegmentingDistance = val; }
101
102 /**
103 * The name of the GraphObject to be reshaped.
104 * The default name is "SHAPE".
105 */
106 get reshapeObjectName(): string { return this._reshapeObjectName; }
107 set reshapeObjectName(value: string) { this._reshapeObjectName = value; }
108
109 /**
110 * This read-only property returns the {@link GraphObject} that is the tool handle being dragged by the user.
111 * This will be contained by an {@link Adornment} whose category is "GeometryReshaping".
112 * Its {@link Adornment#adornedObject} is the same as the {@link #adornedShape}.
113 */
114 get handle(): go.GraphObject | null { return this._handle; }
115 set handle(val: go.GraphObject | null) { this._handle = val; }
116
117 /**
118 * Gets the {@link Shape} that is being reshaped.
119 * This must be contained within the selected Part.
120 */
121 get adornedShape(): go.Shape | null { return this._adornedShape; }
122
123 /**
124 * This read-only property remembers the original value for {@link Shape#geometry},
125 * so that it can be restored if this tool is cancelled.
126 */
127 get originalGeometry(): go.Geometry | null { return this._originalGeometry; }
128
129 /**
130 * Show an {@link Adornment} with a reshape handle at each point of the geometry.
131 * Don't show anything if {@link #reshapeObjectName} doesn't return a {@link Shape}
132 * that has a {@link Shape#geometry} of type {@link Geometry.Path}.
133 */
134 public updateAdornments(part: go.Part): void {
135 if (part === null || part instanceof go.Link) return; // this tool never applies to Links
136 if (part.isSelected && !this.diagram.isReadOnly) {
137 const selelt = part.findObject(this.reshapeObjectName);
138 if (selelt instanceof go.Shape && selelt.geometry !== null &&
139 selelt.actualBounds.isReal() && selelt.isVisibleObject() &&
140 part.canReshape() && part.actualBounds.isReal() && part.isVisible() &&
141 selelt.geometry.type === go.Geometry.Path) {
142 const geo = selelt.geometry;
143 let adornment = part.findAdornment(this.name);
144 if (adornment === null || (this._countHandles(geo) !== adornment.elements.count - 1)) {
145 adornment = this.makeAdornment(selelt);
146 }
147 if (adornment !== null) {
148 // update the position/alignment of each handle
149 const b = geo.bounds;
150 // update the size of the adornment
151 const body = adornment.findObject('BODY');
152 if (body !== null) body.desiredSize = b.size;
153 let unneeded = null;
154 const elts = adornment.elements;
155 for (let i = 0; i < elts.count; i++) {
156 const h = adornment.elt(i);
157 if (typeof (h as any)._typ !== "number") continue;
158 const typ = (h as any)._typ as number;
159 if (typeof (h as any)._fig !== "number") continue;
160 const figi = (h as any)._fig as number;
161 if (figi >= geo.figures.count) {
162 if (unneeded === null) unneeded = [];
163 unneeded.push(h);
164 continue;
165 }
166 var fig = geo.figures.elt(figi);
167 if (typeof (h as any)._seg !== "number") continue;
168 const segi = (h as any)._seg as number;
169 if (segi >= fig.segments.count) {
170 if (unneeded === null) unneeded = [];
171 unneeded.push(h);
172 continue;
173 }
174 var seg = fig.segments.elt(segi);
175 var x = 0;
176 var y = 0;
177 switch (typ) {
178 case 0: x = fig.startX; y = fig.startY; break;
179 case 1: x = seg.endX; y = seg.endY; break;
180 case 2: x = seg.point1X; y = seg.point1Y; break;
181 case 3: x = seg.point2X; y = seg.point2Y; break;
182 case 4: x = (fig.startX + seg.endX) / 2; y = (fig.startY + seg.endY) / 2; break;
183 case 5: x = (fig.segments.elt(segi-1).endX + seg.endX) / 2; y = (fig.segments.elt(segi-1).endY + seg.endY) / 2; break;
184 case 6: x = (fig.startX + seg.endX) / 2; y = (fig.startY + seg.endY) / 2; break;
185 default: throw new Error('unexpected handle type')
186 }
187 h.alignment = new go.Spot(0, 0, x - b.x, y - b.y);
188 }
189 if (unneeded !== null) {
190 unneeded.forEach(function(h) { if (adornment) adornment.remove(h); });
191 }
192
193 part.addAdornment(this.name, adornment);
194 adornment.location = selelt.getDocumentPoint(go.Spot.TopLeft);
195 adornment.angle = selelt.getDocumentAngle();
196 return;
197 }
198 }
199 }
200 part.removeAdornment(this.name);
201 }
202
203 /**
204 * @hidden @internal
205 */
206 private _countHandles(geo: go.Geometry): number {
207 var reseg = this.isResegmenting;
208 var c = 0;
209 geo.figures.each(function(fig) {
210 c++;
211 fig.segments.each(function(seg) {
212 if (reseg) {
213 if (seg.type === go.PathSegment.Line) c++;
214 if (seg.isClosed) c++;
215 }
216 c++;
217 if (seg.type === go.PathSegment.QuadraticBezier) c++;
218 else if (seg.type === go.PathSegment.Bezier) c += 2;
219 })
220 });
221 return c;
222 };
223
224 /**
225 * @hidden @internal
226 */
227 public makeAdornment(selelt: go.Shape): go.Adornment {
228 const adornment = new go.Adornment();
229 adornment.type = go.Panel.Spot;
230 adornment.locationObjectName = 'BODY';
231 adornment.locationSpot = new go.Spot(0, 0, -selelt.strokeWidth / 2, -selelt.strokeWidth / 2);
232 let h: any = new go.Shape();
233 h.name = 'BODY';
234 h.fill = null;
235 h.stroke = null;
236 h.strokeWidth = 0;
237 adornment.add(h);
238
239 const geo = selelt.geometry;
240 if (geo !== null) {
241 if (this.isResegmenting) {
242 for (let f = 0; f < geo.figures.count; f++) {
243 const fig = geo.figures.elt(f);
244 for (let g = 0; g < fig.segments.count; g++) {
245 const seg = fig.segments.elt(g);
246 let h: go.GraphObject | null;
247 if (seg.type === go.PathSegment.Line) {
248 h = this.makeResegmentHandle(selelt, fig, seg);
249 if (h !== null) {
250 (h as any)._typ = (g === 0) ? 4 : 5;
251 (h as any)._fig = f;
252 (h as any)._seg = g;
253 adornment.add(h);
254 }
255 }
256 if (seg.isClosed) {
257 h = this.makeResegmentHandle(selelt, fig, seg);
258 if (h !== null) {
259 (h as any)._typ = 6;
260 (h as any)._fig = f;
261 (h as any)._seg = g;
262 adornment.add(h);
263 }
264 }
265 }
266 }
267 }
268
269 // requires Path Geometry, checked above in updateAdornments
270 for (let f = 0; f < geo.figures.count; f++) {
271 const fig = geo.figures.elt(f);
272 for (let g = 0; g < fig.segments.count; g++) {
273 const seg = fig.segments.elt(g);
274 if (g === 0) {
275 h = this.makeHandle(selelt, fig, seg);
276 if (h !== null) {
277 h._typ = 0;
278 h._fig = f;
279 h._seg = g;
280 adornment.add(h);
281 }
282 }
283 h = this.makeHandle(selelt, fig, seg);
284 if (h !== null) {
285 h._typ = 1;
286 h._fig = f;
287 h._seg = g;
288 adornment.add(h);
289 }
290 if (seg.type === go.PathSegment.QuadraticBezier || seg.type === go.PathSegment.Bezier) {
291 h = this.makeHandle(selelt, fig, seg);
292 if (h !== null) {
293 h._typ = 2;
294 h._fig = f;
295 h._seg = g;
296 adornment.add(h);
297 }
298 if (seg.type === go.PathSegment.Bezier) {
299 h = this.makeHandle(selelt, fig, seg);
300 if (h !== null) {
301 h._typ = 3;
302 h._fig = f;
303 h._seg = g;
304 adornment.add(h);
305 }
306 }
307 }
308 }
309 }
310 }
311 adornment.category = this.name;
312 adornment.adornedObject = selelt;
313 return adornment;
314 }
315
316 /**
317 * @hidden @internal
318 */
319 public makeHandle(selelt: go.Shape, fig: go.PathFigure, seg: go.PathSegment): go.GraphObject | null {
320 const h = this.handleArchetype;
321 if (h === null) return null;
322 return h.copy();
323 }
324
325 /**
326 * @hidden @internal
327 */
328 public makeResegmentHandle(pathshape: go.Shape, fig: go.PathFigure, seg: go.PathSegment) {
329 var h = this.midHandleArchetype;
330 if (h === null) return null;
331 return h.copy();
332 }
333
334
335 /**
336 * This tool may run when there is a mouse-down event on a reshape handle.
337 */
338 public canStart(): boolean {
339 if (!this.isEnabled) return false;
340
341 const diagram = this.diagram;
342 if (diagram.isReadOnly) return false;
343 if (!diagram.allowReshape) return false;
344 if (!diagram.lastInput.left) return false;
345 const h = this.findToolHandleAt(diagram.firstInput.documentPoint, this.name);
346 return (h !== null);
347 }
348
349 /**
350 * Start reshaping, if {@link #findToolHandleAt} finds a reshape handle at the mouse down point.
351 *
352 * If successful this sets {@link #handle} to be the reshape handle that it finds
353 * and {@link #adornedShape} to be the {@link Shape} being reshaped.
354 * It also remembers the original geometry in case this tool is cancelled.
355 * And it starts a transaction.
356 */
357 public doActivate(): void {
358 const diagram = this.diagram;
359 if (diagram === null) return;
360 this._handle = this.findToolHandleAt(diagram.firstInput.documentPoint, this.name);
361 const h = this._handle;
362 if (h === null) return;
363 const shape = (h.part as go.Adornment).adornedObject as go.Shape;
364 if (!shape || !shape.part) return;
365 this._adornedShape = shape;
366 diagram.isMouseCaptured = true;
367 this.startTransaction(this.name);
368
369 const typ = (h as any)._typ as number;
370 const figi = (h as any)._fig as number;
371 const segi = (h as any)._seg as number;
372 if (this.isResegmenting && typ >= 4 && shape.geometry !== null) {
373 const locpt = shape.getLocalPoint(diagram.firstInput.documentPoint);
374 const geo = shape.geometry.copy();
375 const fig = geo.figures.elt(figi);
376 const seg = fig.segments.elt(segi);
377 const newseg = seg.copy();
378 switch (typ) {
379 case 4: {
380 newseg.endX = (fig.startX + seg.endX) / 2;
381 newseg.endY = (fig.startY + seg.endY) / 2;
382 newseg.isClosed = false;
383 fig.segments.insertAt(segi, newseg);
384 break;
385 }
386 case 5: {
387 const prevseg = fig.segments.elt(segi - 1);
388 newseg.endX = (prevseg.endX + seg.endX) / 2;
389 newseg.endY = (prevseg.endY + seg.endY) / 2;
390 newseg.isClosed = false;
391 fig.segments.insertAt(segi, newseg);
392 break;
393 }
394 case 6: {
395 newseg.endX = (fig.startX + seg.endX) / 2;
396 newseg.endY = (fig.startY + seg.endY) / 2;
397 newseg.isClosed = seg.isClosed;
398 seg.isClosed = false;
399 fig.add(newseg);
400 break;
401 }
402 }
403 shape.geometry = geo; // modify the Shape
404 var part = shape.part;
405 part.ensureBounds();
406 this.updateAdornments(part); // update any Adornments of the Part
407 this._handle = this.findToolHandleAt(diagram.firstInput.documentPoint, this.name);
408 if (this._handle === null) {
409 this.doDeactivate(); // need to rollback the transaction and not set .isActive
410 return;
411 }
412 }
413
414 this._originalGeometry = shape.geometry;
415 this.isActive = true;
416 }
417
418 /**
419 * This stops the current reshaping operation with the Shape as it is.
420 */
421 public doDeactivate(): void {
422 this.stopTransaction();
423
424 this._handle = null;
425 this._adornedShape = null;
426 const diagram = this.diagram;
427 if (diagram !== null) diagram.isMouseCaptured = false;
428 this.isActive = false;
429 }
430
431 /**
432 * Restore the shape to be the original geometry and stop this tool.
433 */
434 public doCancel(): void {
435 const shape = this._adornedShape;
436 if (shape !== null) {
437 // explicitly restore the original route, in case !UndoManager.isEnabled
438 shape.geometry = this._originalGeometry;
439 }
440 this.stopTool();
441 }
442
443 /**
444 * Call {@link #reshape} with a new point determined by the mouse
445 * to change the geometry of the {@link #adornedShape}.
446 */
447 public doMouseMove(): void {
448 const diagram = this.diagram;
449 if (this.isActive && diagram !== null) {
450 const newpt = this.computeReshape(diagram.lastInput.documentPoint);
451 this.reshape(newpt);
452 }
453 }
454
455 /**
456 * Reshape the Shape's geometry with a point based on the most recent mouse point by calling {@link #reshape},
457 * and then stop this tool.
458 */
459 public doMouseUp(): void {
460 const diagram = this.diagram;
461 if (this.isActive && diagram !== null) {
462 const newpt = this.computeReshape(diagram.lastInput.documentPoint);
463 this.reshape(newpt);
464 const shape = this.adornedShape;
465 if (this.isResegmenting && shape && shape.geometry && shape.part) {
466 const typ = (this.handle as any)._typ as number;
467 const figi = (this.handle as any)._fig as number;
468 const segi = (this.handle as any)._seg as number;
469 const fig = shape.geometry.figures.elt(figi);
470 if (fig && fig.segments.count > 2) { // avoid making a degenerate polygon
471 let ax, ay, bx, by, cx, cy;
472 if (typ === 0) {
473 const lastseg = fig.segments.length-1;
474 ax = fig.segments.elt(lastseg).endX; ay = fig.segments.elt(lastseg).endY;
475 bx = fig.startX; by = fig.startY;
476 cx = fig.segments.elt(0).endX; cy = fig.segments.elt(0).endY;
477 } else {
478 if (segi <= 0) {
479 ax = fig.startX; ay = fig.startY;
480 } else {
481 ax = fig.segments.elt(segi - 1).endX; ay = fig.segments.elt(segi - 1).endY;
482 }
483 bx = fig.segments.elt(segi).endX; by = fig.segments.elt(segi).endY;
484 if (segi >= fig.segments.length-1) {
485 cx = fig.startX; cy = fig.startY;
486 } else {
487 cx = fig.segments.elt(segi + 1).endX; cy = fig.segments.elt(segi + 1).endY;
488 }
489 }
490 const q = new go.Point(bx, by);
491 q.projectOntoLineSegment(ax, ay, cx, cy);
492 // if B is within resegmentingDistance of the line from A to C,
493 // and if Q is between A and C, remove that point from the geometry
494 const dist = q.distanceSquaredPoint(new go.Point(bx, by));
495 if (dist < this.resegmentingDistance * this.resegmentingDistance) {
496 const geo = shape.geometry.copy();
497 const fig = geo.figures.elt(figi);
498 if (typ === 0) {
499 const first = fig.segments.first();
500 if (first) { fig.startX = first.endX; fig.startY = first.endY; }
501 }
502 if (segi > 0) {
503 const prev = fig.segments.elt(segi - 1);
504 const seg = fig.segments.elt(segi);
505 prev.isClosed = seg.isClosed;
506 }
507 fig.segments.removeAt(segi);
508 shape.geometry = geo;
509 shape.part.removeAdornment(this.name);
510 this.updateAdornments(shape.part);
511 }
512 }
513 }
514 this.transactionResult = this.name; // success
515 }
516 this.stopTool();
517 }
518
519 /**
520 * Change the geometry of the {@link #adornedShape} by moving the point corresponding to the current
521 * {@link #handle} to be at the given {@link Point}.
522 * This is called by {@link #doMouseMove} and {@link #doMouseUp} with the result of calling
523 * {@link #computeReshape} to constrain the input point.
524 * @param {Point} newPoint the value of the call to {@link #computeReshape}.
525 */
526 public reshape(newPoint: go.Point): void {
527 const shape = this.adornedShape;
528 if (shape === null || shape.geometry === null) return;
529 const locpt = shape.getLocalPoint(newPoint);
530 const geo = shape.geometry.copy();
531 const h = this.handle;
532 if (!h) return;
533 const type = (h as any)._typ;
534 if (type === undefined) return;
535 if ((h as any)._fig >= geo.figures.count) return;
536 const fig = geo.figures.elt((h as any)._fig);
537 if ((h as any)._seg >= fig.segments.count) return;
538 const seg = fig.segments.elt((h as any)._seg);
539 switch (type) {
540 case 0: fig.startX = locpt.x; fig.startY = locpt.y; break;
541 case 1: seg.endX = locpt.x; seg.endY = locpt.y; break;
542 case 2: seg.point1X = locpt.x; seg.point1Y = locpt.y; break;
543 case 3: seg.point2X = locpt.x; seg.point2Y = locpt.y; break;
544 }
545 const offset = geo.normalize(); // avoid any negative coordinates in the geometry
546 shape.desiredSize = new go.Size(NaN, NaN); // clear the desiredSize so Geometry can determine size
547 shape.geometry = geo; // modify the Shape
548 const part = shape.part; // move the Part holding the Shape
549 if (part === null) return;
550 part.ensureBounds();
551 if (part.locationObject !== shape && !part.locationSpot.equals(go.Spot.Center)) { // but only if the locationSpot isn't Center
552 // support the whole Node being rotated
553 part.move(part.position.copy().subtract(offset.rotate(part.angle)));
554 }
555 this.updateAdornments(part); // update any Adornments of the Part
556 this.diagram.maybeUpdate(); // force more frequent drawing for smoother looking behavior
557 }
558
559 /**
560 * This is called by {@link #doMouseMove} and {@link #doMouseUp} to limit the input point
561 * before calling {@link #reshape}.
562 * By default, this doesn't limit the input point.
563 * @param {Point} p the point where the handle is being dragged.
564 * @return {Point}
565 */
566 public computeReshape(p: go.Point): go.Point {
567 return p; // no constraints on the points
568 }
569}