1----------------------------------------------------------------------
2-- tools.lua
3----------------------------------------------------------------------
4--[[
5
6    This file is part of the extensible drawing editor Ipe.
7    Copyright (c) 1993-2020 Otfried Cheong
8
9    Ipe is free software; you can redistribute it and/or modify it
10    under the terms of the GNU General Public License as published by
11    the Free Software Foundation; either version 3 of the License, or
12    (at your option) any later version.
13
14    As a special exception, you have permission to link Ipe with the
15    CGAL library and distribute executables, as long as you follow the
16    requirements of the Gnu General Public License in regard to all of
17    the software in the executable aside from CGAL.
18
19    Ipe is distributed in the hope that it will be useful, but WITHOUT
20    ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
21    or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public
22    License for more details.
23
24    You should have received a copy of the GNU General Public License
25    along with Ipe; if not, you can find it at
26    "http://www.gnu.org/copyleft/gpl.html", or write to the Free
27    Software Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
28
29--]]
30
31function externalEditor(d, field)
32  local text = d:get(field)
33  local fname = os.tmpname()
34  if prefs.editable_textfile then
35    fname = prefs.editable_textfile
36  end
37  local f = io.open(fname, "w")
38  f:write(text)
39  f:close()
40  ipeui.waitDialog(d, string.format(prefs.external_editor, fname))
41  f = io.open(fname, "r")
42  text = f:read("*all")
43  f:close()
44  os.remove(fname)
45  d:set(field, text)
46  if prefs.editor_closes_dialog and not prefs.auto_external_editor then
47    d:accept(true)
48  end
49end
50
51function addEditorField(d, field)
52  if prefs.external_editor then
53    d:addButton("editor", "&Editor", function (d) externalEditor(d, field) end)
54  end
55end
56
57----------------------------------------------------------------------
58
59local function circleshape(center, radius)
60  return { type="ellipse";
61	   ipe.Matrix(radius, 0, 0, radius, center.x, center.y) }
62end
63
64local function segmentshape(v1, v2)
65  return { type="curve", closed=false; { type="segment"; v1, v2 } }
66end
67
68local function boxshape(v1, v2)
69  return { type="curve", closed=true;
70	   { type="segment"; v1, V(v1.x, v2.y) },
71	   { type="segment"; V(v1.x, v2.y), v2 },
72	   { type="segment"; v2, V(v2.x, v1.y) } }
73end
74
75local function arcshape(center, radius, alpha, beta)
76  local a = ipe.Arc(ipe.Matrix(radius, 0, 0, radius, center.x, center.y),
77		    alpha, beta)
78  local v1 = center + radius * ipe.Direction(alpha)
79  local v2 = center + radius * ipe.Direction(beta)
80  return { type="curve", closed=false; { type="arc", arc=a; v1, v2 } }
81end
82
83local function rarcshape(center, radius, alpha, beta)
84  local a = ipe.Arc(ipe.Matrix(radius, 0, 0, -radius, center.x, center.y),
85		    alpha, beta)
86  local v1 = center + radius * ipe.Direction(alpha)
87  local v2 = center + radius * ipe.Direction(beta)
88  return { type="curve", closed=false; { type="arc", arc=a; v1, v2 } }
89end
90
91----------------------------------------------------------------------
92
93local VERTEX = 1
94local SPLINE = 2
95local ARC = 3
96
97LINESTOOL = {}
98LINESTOOL.__index = LINESTOOL
99
100-- mode is one of "lines", "polygons", "splines"
101function LINESTOOL:new(model, mode)
102  local tool = {}
103  setmetatable(tool, LINESTOOL)
104  tool.model = model
105  tool.mode = mode
106  tool.splinetype = "spline"
107  if model.attributes.splinetype == "cardinal" then
108    tool.splinetype = "cardinal"
109  elseif model.attributes.splinetype == "spiro" then
110    tool.splinetype = "spiro"
111  end
112  tool.tension = model.attributes.tension
113  local v = model.ui:pos()
114  tool.v = { v, v }
115  model.ui:setAutoOrigin(v)
116  if mode == "splines" then
117    tool.t = { VERTEX, SPLINE }
118  else
119    tool.t = { VERTEX, VERTEX }
120  end
121  model.ui:shapeTool(tool)
122  tool.setColor(1.0, 0, 0)
123  tool.setSnapping(true, true)
124  return tool
125end
126
127function LINESTOOL:last()
128  return self.t[#self.t]
129end
130
131function LINESTOOL:has_segs(count)
132  if #self.t < count then return false end
133  local result = true
134  for i = #self.t - count + 1,#self.t do
135    if self.t[i] ~= VERTEX then return false end
136  end
137  return true
138end
139
140local function compute_arc(p0, pmid, p1)
141  if p0 == p1 then return { type="segment", p0, p1 } end
142  local l1 = ipe.Line(p0, (pmid- p0):normalized())
143  local l2 = ipe.Line(p0, l1:normal())
144  local bi1 = ipe.Bisector(p0, p1)
145  local center = l2:intersects(bi1)
146  local side = l1:side(p1)
147  if side == 0.0 or not center then return { type="segment", p0, p1 } end
148  local u = p0 - center
149  local alpha = side * u:angle()
150  local beta = side * ipe.normalizeAngle((p1 - center):angle(), alpha)
151  local radius = u:len()
152  local m = { radius, 0, 0, side * radius, center.x, center.y }
153  return { type = "arc", p0, p1, arc = ipe.Arc(ipe.Matrix(m), alpha, beta) }
154end
155
156-- compute orientation of tangent to previous segment at its final point
157function LINESTOOL:compute_orientation()
158  if self.t[#self.t - 2] ~= ARC then
159    return (self.v[#self.v - 1] - self.v[#self.v - 2]):angle()
160  else
161    -- only arc needs special handling
162    local arc = compute_arc(self.v[#self.v - 3], self.v[#self.v - 2],
163			    self.v[#self.v - 1])
164    if arc.type == "arc" then
165      local alpha, beta = arc.arc:angles()
166      local dir = ipe.Direction(beta + math.pi / 2)
167      return (arc.arc:matrix():linear() * dir):angle()
168    else
169      return (self.v[#self.v - 1] - self.v[#self.v - 3]):angle()
170    end
171  end
172end
173
174function LINESTOOL:compute()
175  self.shape = { type="curve", closed=(self.mode == "polygons") }
176  local i = 2
177  while i <= #self.v do
178    -- invariant: t[i-1] == VERTEX
179    local seg
180    if self.t[i] == VERTEX then
181      seg = { type="segment", self.v[i-1], self.v[i] }
182      i = i + 1
183    elseif self.t[i] == SPLINE then
184      local j = i
185      while j <= #self.v and self.t[j] == SPLINE do j = j + 1 end
186      if j > #self.v then j = #self.v end
187      seg = { type = self.splinetype }
188      if self.splinetype == "cardinal" then seg.tension = prefs.spline_tension end
189      for k = i-1,j do seg[#seg+1] = self.v[k] end
190      i = j + 1
191    elseif self.t[i] == ARC then
192      seg = compute_arc(self.v[i-1], self.v[i], self.v[i+1])
193      i = i + 2
194    end
195    self.shape[#self.shape + 1] = seg
196  end
197  self.setShape( { self.shape } )
198end
199
200function LINESTOOL:mouseButton(button, modifiers, press)
201  if not press then return end
202  local v = self.model.ui:pos()
203  self.v[#self.v] = v
204  if button == 0x81 then
205    -- double click
206    -- the first click already added a vertex, do not use second one
207    if #self.v == 2 then return end
208    table.remove(self.v)
209    table.remove(self.t)
210    button = 2
211  end
212  if modifiers.control and button == 1 then button = 2 end
213  if button == 2 then
214    if self:last() == SPLINE then self.t[#self.t] = VERTEX end
215    self:compute()
216    self.model.ui:finishTool()
217    local obj = ipe.Path(self.model.attributes, { self.shape }, true)
218    -- if it is just a point, force line cap to round
219    if #self.shape == 1 and self.shape[1].type == "segment" and
220      self.shape[1][1] == self.shape[1][2] then
221      obj:set("linecap", "round")
222    end
223    self.model:creation("create path", obj)
224    return
225  end
226  self.v[#self.v + 1] = v
227  self.model.ui:setAutoOrigin(v)
228  if self:last() == SPLINE then
229    local typ = modifiers.shift and VERTEX or SPLINE
230    self.t[#self.t] = typ
231    self.t[#self.t + 1] = typ
232  else
233    self.t[#self.t + 1] = VERTEX
234  end
235  self:compute()
236  self.model.ui:update(false) -- update tool
237  self:explain()
238end
239
240function LINESTOOL:mouseMove()
241  self.v[#self.v] = self.model.ui:pos()
242  self:compute()
243  self.model.ui:update(false) -- update tool
244  self:explain()
245end
246
247function LINESTOOL:explain()
248  local s
249  if self:last() == SPLINE then
250    s = ("Left: add ctrl point | Shift+Left: switch to line mode" ..
251	 " | Del: delete ctrl point")
252  else
253    s = "Left: add vtx | Del: delete vtx"
254  end
255  s = s .. " | Right, Ctrl-Left, or Double-Left: final vtx"
256  if self:has_segs(2) then
257    s = s .. " | " .. shortcuts_linestool.spline .. ": spline mode"
258  end
259  if self:has_segs(3) then
260    s = s .. " | " .. shortcuts_linestool.arc .. ": circle arc"
261  end
262  if #self.v > 2 and self.t[#self.t - 1] == VERTEX then
263    s = s .. " | " .. shortcuts_linestool.set_axis ..": set axis"
264  end
265  self.model.ui:explain(s, 0)
266end
267
268function LINESTOOL:key(text, modifiers)
269  if text == prefs.delete_key then  -- Delete
270    if #self.v > 2 then
271      table.remove(self.v)
272      table.remove(self.t)
273      if self:last() == ARC then
274	self.t[#self.t] = VERTEX
275      end
276      self:compute()
277      self.model.ui:update(false)
278      self:explain()
279    else
280      self.model.ui:finishTool()
281    end
282    return true
283  elseif text == "\027" then
284    self.model.ui:finishTool()
285    return true
286  elseif self:has_segs(2) and text == shortcuts_linestool.spline then
287    self.t[#self.t] = SPLINE
288    self:compute()
289    self.model.ui:update(false)
290    self:explain()
291    return true
292  elseif self:has_segs(3) and text == shortcuts_linestool.arc then
293    self.t[#self.t - 1] = ARC
294    self:compute()
295    self.model.ui:update(false)
296    self:explain()
297    return true
298  elseif #self.v > 2 and self.t[#self.t - 1] == VERTEX and
299    text == shortcuts_linestool.set_axis then
300    -- set axis
301    self.model.snap.with_axes = true
302    self.model.ui:setActionState("show_axes", self.model.snap.with_axes)
303    self.model.snap.snapangle = true
304    self.model.snap.origin = self.v[#self.v - 1]
305    self.model.snap.orientation = self:compute_orientation()
306    self.model:setSnap()
307    self.model.ui:setActionState("snapangle", true)
308    self.model.ui:update(true)   -- redraw coordinate system
309    self:explain()
310    return true
311  else
312    return false
313  end
314end
315
316----------------------------------------------------------------------
317
318BOXTOOL = {}
319BOXTOOL.__index = BOXTOOL
320
321function BOXTOOL:new(model, square, mode)
322  local tool = {}
323  setmetatable(tool, BOXTOOL)
324  tool.model = model
325  tool.mode = mode
326  tool.square = square
327  local v = model.ui:pos()
328  tool.v = { v, v }
329  model.ui:shapeTool(tool)
330  tool.cur = 2
331  tool.setColor(1.0, 0, 0)
332  return tool
333end
334
335function BOXTOOL:compute()
336  self.v[self.cur] = self.model.ui:pos()
337  local d = self.v[2] - self.v[1]
338  local sign = V(d.x > 0 and 1 or -1, d.y > 0 and 1 or -1)
339  local dd = math.max(math.abs(d.x), math.abs(d.y))
340  if self.mode == "rectangles1" or self.mode == "rectangles2" then
341    if self.square then
342      self.v[2] = self.v[1] + dd * sign
343    end
344    if self.mode == "rectangles1" then
345      self.shape = boxshape(self.v[1], self.v[2])
346    else
347      local u = self.v[2] - self.v[1]
348      self.shape = boxshape(self.v[1] - u, self.v[2])
349    end
350  elseif self.mode == "rectangles3" and self.square then
351    local u = self.v[2] - self.v[1]
352    local lu = V(-u.y, u.x)
353    self.shape = { type="curve", closed=true;
354		   { type="segment"; self.v[1], self.v[2] },
355		   { type="segment"; self.v[2], self.v[2] + lu },
356		   { type="segment"; self.v[2] + lu, self.v[1] + lu } }
357  else
358    if self.cur == 2 and self.square then -- make first-segment axis-parallel
359      if math.abs(d.x) > math.abs(d.y) then
360	self.v[2] = self.v[1] + V(sign.x * dd, 0)
361      else
362	self.v[2] = self.v[1] + V(0, sign.y * dd)
363      end
364    end
365    if self.cur == 2 then
366      self.shape = segmentshape(self.v[1], self.v[2])
367    else
368      if self.v[2] ~= self.v[1] and self.v[3] ~= self.v[2] and
369	self.mode == "rectangles3" then
370	local l1 = ipe.LineThrough(self.v[1], self.v[2])
371	local l2 = ipe.Line(self.v[2], l1:normal())
372	self.v[3] = l2:project(self.v[3])
373      end
374      local u = self.v[3] - self.v[2]
375      self.shape = { type="curve", closed=true;
376		     { type="segment"; self.v[1], self.v[2] },
377		     { type="segment"; self.v[2], self.v[3] },
378		     { type="segment"; self.v[3], self.v[1] + u } }
379    end
380  end
381end
382
383function BOXTOOL:mouseButton(button, modifiers, press)
384  if not press then return end
385  self:compute()
386  if self.cur == 2 and (self.mode == "parallelogram" or
387			(self.mode == "rectangles3" and not self.square)) then
388    self.cur = 3
389    self.v[3] = self.v[2]
390    return
391  end
392  self.model.ui:finishTool()
393  local obj = ipe.Path(self.model.attributes, { self.shape })
394  self.model:creation("create box", obj)
395end
396
397function BOXTOOL:mouseMove()
398  self:compute()
399  self.setShape( { self.shape } )
400  self.model.ui:update(false) -- update tool
401end
402
403function BOXTOOL:key(text, modifiers)
404  if text == "\027" then
405    self.model.ui:finishTool()
406    return true
407  else
408    return false
409  end
410end
411
412----------------------------------------------------------------------
413
414SPLINEGONTOOL = {}
415SPLINEGONTOOL.__index = SPLINEGONTOOL
416
417function SPLINEGONTOOL:new(model)
418  local tool = {}
419  setmetatable(tool, SPLINEGONTOOL)
420  tool.model = model
421  local v = model.ui:pos()
422  tool.v = { v, v }
423  model.ui:shapeTool(tool)
424  tool.setColor(1.0, 0, 0)
425  return tool
426end
427
428function SPLINEGONTOOL:compute(update)
429  if #self.v == 2 then
430    self.shape = segmentshape(self.v[1], self.v[2])
431  else
432    self.shape = { type="closedspline" }
433    for _,v in ipairs(self.v) do self.shape[#self.shape + 1] = v end
434  end
435  if update then
436    self.setShape( { self.shape } )
437    self.model.ui:update(false) -- update tool
438  end
439end
440
441function SPLINEGONTOOL:explain()
442  local s = "Left: Add vertex "
443    .. "| Right, ctrl-left, or double-left: Add final vertex"
444    .. "| Del: Delete vertex"
445  self.model.ui:explain(s, 0)
446end
447
448function SPLINEGONTOOL:mouseButton(button, modifiers, press)
449  if not press then return end
450  local v = self.model.ui:pos()
451  self.v[#self.v] = v
452  if button == 0x81 then
453    -- double click
454    -- the first click already added a vertex, do not use second one
455    if #self.v == 2 then return end
456    table.remove(self.v)
457    button = 2
458  end
459  if modifiers.control and button == 1 then button = 2 end
460  if button == 2 then
461    self:compute(false)
462    self.model.ui:finishTool()
463    local obj = ipe.Path(self.model.attributes, { self.shape })
464    self.model:creation("create splinegon", obj)
465    return
466  end
467  self.v[#self.v + 1] = v
468  self:compute(true)
469  self:explain()
470end
471
472function SPLINEGONTOOL:mouseMove()
473  self.v[#self.v] = self.model.ui:pos()
474  self:compute(true)
475end
476
477function SPLINEGONTOOL:key(text, modifiers)
478  if text == prefs.delete_key then  -- Delete
479    if #self.v > 2 then
480      table.remove(self.v)
481      self:compute(true)
482      self:explain()
483    else
484      self.model.ui:finishTool()
485    end
486    return true
487  elseif text == "\027" then
488    self.model.ui:finishTool()
489    return true
490  else
491    self:explain()
492    return false
493  end
494end
495
496----------------------------------------------------------------------
497
498CIRCLETOOL = {}
499CIRCLETOOL.__index = CIRCLETOOL
500
501function CIRCLETOOL:new(model, mode)
502  local tool = {}
503  setmetatable(tool, CIRCLETOOL)
504  tool.model = model
505  tool.mode = mode
506  local v = model.ui:pos()
507  tool.v = { v, v, v }
508  tool.cur = 2
509  model.ui:shapeTool(tool)
510  tool.setColor(1.0, 0, 0)
511  return tool
512end
513
514function CIRCLETOOL:compute()
515  if self.mode == "circle1" then
516    self.shape = circleshape(self.v[1], (self.v[2] - self.v[1]):len())
517  elseif self.mode == "circle2" then
518    self.shape = circleshape(0.5 * (self.v[1] + self.v[2]),
519			     (self.v[2] - self.v[1]):len() / 2.0)
520  elseif self.mode == "circle3" then
521    if self.cur == 2 or self.v[3] == self.v[2] then
522      self.shape = circleshape(0.5 * (self.v[1] + self.v[2]),
523			       (self.v[2] - self.v[1]):len() / 2.0)
524    else
525      local l1 = ipe.LineThrough(self.v[1], self.v[2])
526      if math.abs(l1:side(self.v[3])) < 1e-9 then
527	self.shape = segmentshape(self.v[1], self.v[2])
528      else
529	local l2 = ipe.LineThrough(self.v[2], self.v[3])
530	local bi1 = ipe.Bisector(self.v[1], self.v[2])
531	local bi2 = ipe.Bisector(self.v[2], self.v[3])
532	local center = bi1:intersects(bi2)
533	self.shape = circleshape(center, (self.v[1] - center):len())
534      end
535    end
536  end
537end
538
539function CIRCLETOOL:mouseButton(button, modifiers, press)
540  if not press then return end
541  local v = self.model.ui:pos()
542  -- refuse point identical to previous
543  if v == self.v[self.cur - 1] then return end
544  self.v[self.cur] = v
545  self:compute()
546  if self.cur == 3 or (self.mode ~= "circle3" and self.cur == 2) then
547    self.model.ui:finishTool()
548    local obj = ipe.Path(self.model.attributes, { self.shape })
549    self.model:creation("create circle", obj)
550  else
551    self.cur = self.cur + 1
552    self.model.ui:update(false)
553  end
554end
555
556function CIRCLETOOL:mouseMove()
557  self.v[self.cur] = self.model.ui:pos()
558  self:compute()
559  self.setShape({ self.shape })
560  self.model.ui:update(false) -- update tool
561end
562
563function CIRCLETOOL:key(text, modifiers)
564  if text == "\027" then
565    self.model.ui:finishTool()
566    return true
567  else
568    return false
569  end
570end
571
572----------------------------------------------------------------------
573
574ARCTOOL = {}
575ARCTOOL.__index = ARCTOOL
576
577function ARCTOOL:new(model, mode)
578  local tool = {}
579  setmetatable(tool, ARCTOOL)
580  tool.model = model
581  tool.mode = mode
582  local v = model.ui:pos()
583  tool.v = { v, v, v }
584  tool.cur = 2
585  model.ui:shapeTool(tool)
586  tool.setColor(1.0, 0, 0)
587  return tool
588end
589
590function ARCTOOL:compute()
591  local u = self.v[2] - self.v[1]
592  if self.cur == 2 then
593    if self.mode == "arc3" then
594      self.shape = circleshape(0.5 * (self.v[1] + self.v[2]), u:len() / 2.0)
595    else
596      self.shape = circleshape(self.v[1], u:len())
597    end
598    return
599  end
600  local alpha = u:angle()
601  local beta = (self.v[3] - self.v[1]):angle()
602  if self.mode == "arc1" then
603    self.shape = arcshape(self.v[1], u:len(), alpha, beta)
604  elseif self.mode == "arc2" then
605    self.shape = rarcshape(self.v[1], u:len(), alpha, beta)
606  else
607    local l1 = ipe.LineThrough(self.v[1], self.v[2])
608    if math.abs(l1:distance(self.v[3])) < 1e-9 or self.v[3] == self.v[2] then
609      self.shape = segmentshape(self.v[1], self.v[3])
610    else
611      local l2 = ipe.LineThrough(self.v[2], self.v[3])
612      local bi1 = ipe.Line(0.5 * (self.v[1] + self.v[2]), l1:normal())
613      local bi2 = ipe.Line(0.5 * (self.v[2] + self.v[3]), l2:normal())
614      local center = bi1:intersects(bi2)
615      u = self.v[1] - center
616      alpha = u:angle()
617      beta = ipe.normalizeAngle((self.v[2] - center):angle(), alpha)
618      local gamma = ipe.normalizeAngle((self.v[3] - center):angle(), alpha)
619      if gamma > beta then
620	self.shape = arcshape(center, u:len(), alpha, gamma)
621      else
622	self.shape = rarcshape(center, u:len(), alpha, gamma)
623      end
624    end
625  end
626end
627
628function ARCTOOL:mouseButton(button, modifiers, press)
629  if not press then return end
630  local v = self.model.ui:pos()
631  -- refuse point identical to previous
632  if v == self.v[self.cur - 1] then return end
633  self.v[self.cur] = v
634  self:compute()
635  if self.cur == 3 then
636    self.model.ui:finishTool()
637    local obj = ipe.Path(self.model.attributes, { self.shape }, true)
638    self.model:creation("create arc", obj)
639  else
640    self.cur = self.cur + 1
641    self.model.ui:update(false)
642  end
643end
644
645function ARCTOOL:mouseMove()
646  self.v[self.cur] = self.model.ui:pos()
647  self:compute()
648  self.setShape({ self.shape })
649  self.model.ui:update(false) -- update tool
650end
651
652function ARCTOOL:key(text, modifiers)
653  if text == "\027" then
654    self.model.ui:finishTool()
655    return true
656  else
657    return false
658  end
659end
660
661----------------------------------------------------------------------
662
663local function path_len(points)
664  local t = 0
665  for i=2,#points do
666    t = t + (points[i] - points[i-1]):len()
667  end
668  return t
669end
670
671-- perform moving average
672local function smooth_path(v)
673  local M = prefs.ink_smoothing
674  local w = { v[1] }
675  for i = 2,#v-1 do
676    local p = V(0,0)
677    for j = i-M, i+M do
678      local q
679      if j < 1 then q = v[1] elseif j > #v then q = v[#v] else q = v[i] end
680      p = p + q
681    end
682    w[#w+1] = (1/(2*M+1)) * p
683  end
684  w[#w+1] = v[#v]
685  return w
686end
687
688local function simplify_path(points, tolerance)
689  local marked = {}
690  marked[1] = true
691  marked[#points] = true
692  local stack = { { 1, #points } }
693
694  -- mark points to keep
695  while #stack > 0 do
696    local pair = table.remove(stack)
697    local first = pair[1]
698    local last = pair[2]
699    local max_dist = 0
700    local index = nil
701    local seg = ipe.Segment(points[first], points[last])
702    for i = first + 1,last do
703      local d = seg:distance(points[i])
704      if d > max_dist then
705        index = i
706        max_dist = d
707      end
708    end
709    if max_dist > tolerance then
710      marked[index] = true
711      stack[#stack + 1] = { first, index }
712      stack[#stack + 1] = { index, last }
713    end
714  end
715  -- only use marked vertices
716  local out = {}
717  for i, point in ipairs(points) do
718    if marked[i] then
719      out[#out+1] = point
720    end
721  end
722  return out
723end
724
725-- vector for control points around p2
726local function tang(p1, p2, p3)
727  return (p2-p1):normalized() + (p3 - p2):normalized()
728end
729
730-- first control point between p2 and p3
731local function cp1(p1, p2, p3)
732  local tangent = tang(p1, p2, p3):normalized()
733  local vlen = (p3 - p2):len() / 3.0
734  return p2 + vlen * tangent
735end
736
737-- second control point between p2 and p3
738local function cp2(p2, p3, p4)
739  local tangent = tang(p2, p3, p4):normalized()
740  local vlen = (p3 - p2):len() / 3.0
741  return p3 - vlen * tangent
742end
743
744local function compute_spline(w)
745  local shape = { }
746  if #w > 2 then
747    local cp = cp2(w[1], w[2], w[3])
748    local seg = { w[1], cp, w[2] }
749    seg.type = "spline"
750    shape[1] = seg
751  else
752    local seg = { w[1], w[2] }
753    seg.type = "segment"
754    shape[1] = seg
755  end
756  for i = 2,#w-2 do
757    local q1 = w[i-1]
758    local q2 = w[i]
759    local q3 = w[i+1]
760    local q4 = w[i+2]
761    local cpa = cp1(q1, q2, q3)
762    local cpb = cp2(q2, q3, q4)
763    local seg = { q2, cpa, cpb, q3}
764    seg.type = "spline"
765    shape[#shape + 1] = seg
766  end
767  if #w > 2 then
768    local cp = cp1(w[#w-2], w[#w-1], w[#w])
769    local seg = { w[#w-1], cp, w[#w] }
770    seg.type = "spline"
771    shape[#shape + 1] = seg
772  end
773  return shape
774end
775
776----------------------------------------------------------------------
777
778INKTOOL = {}
779INKTOOL.__index = INKTOOL
780
781function INKTOOL:new(model)
782  local tool = {}
783  setmetatable(tool, INKTOOL)
784  tool.model = model
785  local v = model.ui:pos()
786  tool.v = { v }
787  -- print("make ink tool")
788  model.ui:shapeTool(tool)
789  local s = model.doc:sheets():find("color", model.attributes.stroke)
790  tool.setColor(s.r, s.g, s.b)
791  tool.w = model.doc:sheets():find("pen", model.attributes.pen)
792  model.ui:setCursor(tool.w, s.r, s.g, s.b)
793  return tool
794end
795
796function INKTOOL:compute(incremental)
797  local v = self.v
798  if incremental and #v > 2 then
799    self.shape[#self.shape+1]={ type="segment", v[#v-1], v[#v]}
800  else
801    self.shape = { type="curve", closed=false }
802    for i = 2, #v do
803      self.shape[#self.shape + 1] = { type="segment", v[i-1], v[i] }
804    end
805  end
806  self.setShape( { self.shape }, 0, self.w * self.model.ui:zoom())
807end
808
809function INKTOOL:mouseButton(button, modifiers, press)
810  if self.shape then
811    self.v = smooth_path(self.v)
812    self.v = simplify_path(self.v, prefs.ink_tolerance / self.model.ui:zoom())
813  else
814    self.v = { self.v[1], self.v[1] }
815  end
816  if prefs.ink_spline then
817    self.shape = compute_spline(self.v)
818    self.shape.type = "curve"
819    self.shape.closed = false
820  else
821    self:compute(false)
822  end
823  local obj = ipe.Path(self.model.attributes, { self.shape })
824  -- round linecaps are prettier for handwriting
825  obj:set("linecap", "round")
826  obj:set("linejoin", "round")
827  if path_len(self.v) * self.model.ui:zoom() < prefs.ink_dot_length then
828    -- pen was pressed and released with no or very little movement
829    if prefs.ink_dot_wider then
830      obj:set("pen", self.w * prefs.ink_dot_wider)
831    end
832    self.model:creation("create ink dot", obj)
833  else
834    self.model:creation("create ink path", obj)
835  end
836  self.model:page():deselectAll()  -- ink isn't selected after creation
837  self.model.ui:finishTool()
838end
839
840function INKTOOL:mouseMove()
841  local v1 = self.v[#self.v]
842  local v2 = self.model.ui:pos()
843  if (v1-v2):len() * self.model.ui:zoom() > prefs.ink_min_movement then
844    -- do not update if motion is too small
845    self.v[#self.v + 1] = v2
846    self:compute(true)
847    local r = ipe.Rect()
848    local offset = V(self.w, self.w)
849    r:add(v1)
850    r:add(v2)
851    r:add(r:bottomLeft() - offset)
852    r:add(r:topRight() + offset)
853    self.model.ui:update(r) -- update rectangle containing new segment
854  end
855end
856
857function INKTOOL:key(text, modifiers)
858  if text == "\027" then
859    self.model.ui:finishTool()
860    return true
861  else
862    return false
863  end
864end
865
866----------------------------------------------------------------------
867
868function MODEL:shredObject()
869  local bound = prefs.close_distance
870  local pos = self.ui:unsnappedPos()
871  local p = self:page()
872
873  local closest
874  for i,obj,sel,layer in p:objects() do
875     if p:visible(self.vno, i) and not p:isLocked(layer) then
876	local d = p:distance(i, pos, bound)
877	if d < bound then closest = i; bound = d end
878     end
879  end
880
881  if closest then
882    local t = { label="shred object",
883		pno = self.pno,
884		vno = self.vno,
885		num = closest,
886		original=self:page():clone(),
887		undo=revertOriginal,
888	      }
889    t.redo = function (t, doc)
890	       local p = doc[t.pno]
891	       p:remove(t.num)
892	       p:ensurePrimarySelection()
893	     end
894    self:register(t)
895  else
896    self.ui:explain("No object found to shred")
897  end
898end
899
900----------------------------------------------------------------------
901
902function MODEL:createMark()
903  local obj = ipe.Reference(self.attributes, self.attributes.markshape,
904			    self.ui:pos())
905  self:creation("create mark", obj)
906end
907
908----------------------------------------------------------------------
909
910function MODEL:createText(mode, pos, width, pinned)
911  local prompt = "Enter Latex source"
912  local stylekind = "labelstyle"
913  local styleatt = self.attributes.labelstyle
914  local explainer = "create text"
915  if mode == "math" then
916    prompt = "Enter Latex source for math formula"
917  end
918  if mode == "paragraph" then
919    styleatt = self.attributes.textstyle
920    stylekind = "textstyle"
921    explainer = "create text paragraph"
922  end
923  local styles = self.doc:sheets():allNames(stylekind)
924  local sizes = self.doc:sheets():allNames("textsize")
925  local d = ipeui.Dialog(self.ui:win(), "Create text object")
926  d:add("label", "label", { label=prompt }, 1, 1, 1, 2)
927  d:add("style", "combo", styles, -1, 3)
928  d:add("size", "combo", sizes, -1, 4)
929  d:add("text", "text", { syntax="latex", focus=true,
930			  spell_check=prefs.spell_check }, 0, 1, 1, 4)
931  addEditorField(d, "text")
932  d:addButton("ok", "&Ok", "accept")
933  d:addButton("cancel", "&Cancel", "reject")
934  d:setStretch("row", 2, 1)
935  d:setStretch("column", 2, 1)
936  d:set("ignore-escape", "text", "")
937  local style = indexOf(styleatt, styles)
938  if not style then style = indexOf("normal", styles) end
939  local size = indexOf(self.attributes.textsize, sizes)
940  if not size then size = indexOf("normal", sizes) end
941  d:set("style", style)
942  d:set("size", size)
943  if mode == "math" then
944    d:set("style", "math")
945    d:setEnabled("style", false)
946  end
947  if prefs.auto_external_editor then
948    externalEditor(d, "text")
949  end
950  if ((prefs.auto_external_editor and prefs.editor_closes_dialog)
951    or d:execute(prefs.editor_size)) then
952    local t = d:get("text")
953    local style = styles[d:get("style")]
954    local size = sizes[d:get("size")]
955    local obj = ipe.Text(self.attributes, t, pos, width)
956    obj:set("textsize", size)
957    obj:set(stylekind, style)
958    if pinned then
959      obj:set("pinned", "horizontal")
960    end
961    self:creation(explainer, obj)
962    self:autoRunLatex()
963  end
964end
965
966----------------------------------------------------------------------
967
968function MODEL:action_insert_text_box()
969  local layout = self.doc:sheets():find("layout")
970  local p = self:page()
971  local r = ipe.Rect()
972  local m = ipe.Matrix()
973  for i, obj, sel, layer in p:objects() do
974    if p:visible(self.vno, i) and obj:type() == "text" then
975      obj:addToBBox(r, m)
976    end
977  end
978  local y = layout.framesize.y
979  if not r:isEmpty() and r:bottom() < layout.framesize.y then
980    y = r:bottom() - layout.paragraph_skip
981  end
982  self:createText("paragraph", ipe.Vector(0, y), layout.framesize.x, true)
983end
984
985----------------------------------------------------------------------
986
987PARAGRAPHTOOL = {}
988PARAGRAPHTOOL.__index = PARAGRAPHTOOL
989
990function PARAGRAPHTOOL:new(model)
991  local tool = {}
992  setmetatable(tool, PARAGRAPHTOOL)
993  tool.model = model
994  local v = model.ui:pos()
995  tool.v = { v, v }
996  model.ui:shapeTool(tool)
997  tool.setColor(1.0, 0, 1.0)
998  return tool
999end
1000
1001function PARAGRAPHTOOL:compute()
1002  self.v[2] = V(self.model.ui:pos().x, self.v[1].y - 20)
1003  self.shape = boxshape(self.v[1], self.v[2])
1004end
1005
1006function PARAGRAPHTOOL:mouseButton(button, modifiers, press)
1007  if not press then return end
1008  self:compute()
1009  self.model.ui:finishTool()
1010  local pos = V(math.min(self.v[1].x, self.v[2].x), self.v[1].y)
1011  local wid = math.abs(self.v[2].x - self.v[1].x)
1012  self.model:createText("paragraph", pos, wid)
1013end
1014
1015function PARAGRAPHTOOL:mouseMove()
1016  self:compute()
1017  self.setShape( { self.shape } )
1018  self.model.ui:update(false) -- update tool
1019end
1020
1021function PARAGRAPHTOOL:key(text, modifiers)
1022  if text == "\027" then
1023    self.model.ui:finishTool()
1024    return true
1025  else
1026    return false
1027  end
1028end
1029
1030----------------------------------------------------------------------
1031
1032local function collect_edges(p, box, view)
1033  local edges = {}
1034  for i, obj, sel, layer in p:objects() do
1035    if obj:type() == "path" and (view == nil or p:visible(view, i)) then
1036      local shape = obj:shape()
1037      local m = obj:matrix()
1038      if #shape == 1 and shape[1].type == "curve"
1039	and shape[1].closed == false then
1040	local curve = shape[1]
1041	local head = curve[1][1]
1042	local seg = curve[#curve]
1043	local tail = seg[#seg]
1044	if box:contains(m * head) then
1045	  edges[#edges + 1] = { obj=obj, head=true, objno=i }
1046	elseif box:contains(m * tail) then
1047	  edges[#edges + 1] = { obj=obj, head=false, objno=i }
1048	end
1049      end
1050    end
1051  end
1052  return edges
1053end
1054
1055----------------------------------------------------------------------
1056
1057GRAPHTOOL = {}
1058GRAPHTOOL.__index = GRAPHTOOL
1059
1060local function findClosestVertex(model)
1061  local bound = prefs.close_distance
1062  local pos = model.ui:unsnappedPos()
1063  local p = model:page()
1064
1065  local closest
1066  for i,obj,sel,layer in p:objects() do
1067    if p:visible(model.vno, i) and not p:isLocked(layer) then
1068      if obj:type() == "group" or obj:type() == "reference" or
1069        obj:type() == "text" then
1070	local d = p:distance(i, pos, bound)
1071	if d < bound then closest = i; bound = d end
1072      end
1073    end
1074  end
1075
1076  if closest then
1077    -- deselect all, and select only closest object
1078    p:deselectAll()
1079    p:setSelect(closest, 1)
1080    return true
1081  else
1082    return p:hasSelection()
1083  end
1084end
1085
1086function GRAPHTOOL:new(model, moveInvisibleEdges)
1087  if not findClosestVertex(model) then return end
1088  local p = model:page()
1089  self.prim = p:primarySelection()
1090  local view = model.vno
1091  if moveInvisibleEdges then view = nil end
1092  local tool = {}
1093  setmetatable(tool, GRAPHTOOL)
1094  tool.model = model
1095  tool.orig = model.ui:pos()
1096  tool.box = p:bbox(self.prim)
1097  tool.edges = collect_edges(p, tool.box, view)
1098  model.ui:shapeTool(tool)
1099  tool.setColor(1.0, 0.0, 0.0)
1100  return tool
1101end
1102
1103function GRAPHTOOL:compute()
1104  self.t = self.model.ui:pos() - self.orig
1105  self.shape = { boxshape(self.box:bottomLeft() + self.t,
1106			  self.box:topRight() + self.t) }
1107  for i = 1,#self.edges do
1108    local obj = self.edges[i].obj
1109    local shape = obj:shape()
1110    transformShape(obj:matrix(), shape)
1111    local curve = shape[1]
1112    if self.edges[i].head then
1113      curve[1][1] = curve[1][1] + self.t
1114    else
1115      local seg = curve[#curve]
1116      seg[#seg] = seg[#seg] + self.t
1117    end
1118    self.shape[i+1] = curve
1119  end
1120end
1121
1122function GRAPHTOOL:mouseMove()
1123  self:compute()
1124  self.setShape(self.shape)
1125  self.model.ui:update(false) -- update tool
1126end
1127
1128local function apply_node_mode(t, doc)
1129  local p = doc[t.pno]
1130  p:transform(t.primary, ipe.Translation(t.translation))
1131  for _,e in ipairs(t.edges) do
1132    local obj = p[e.objno]
1133    local shape = obj:shape()
1134    transformShape(obj:matrix(), shape)
1135    local curve = shape[1]
1136    if e.head then
1137      curve[1][1] = curve[1][1] + t.translation
1138    else
1139      local seg = curve[#curve]
1140      seg[#seg] = seg[#seg] + t.translation
1141    end
1142    p[e.objno]:setShape(shape)
1143    p[e.objno]:setMatrix(ipe.Matrix())
1144  end
1145end
1146
1147function GRAPHTOOL:mouseButton(button, modifiers, press)
1148  if press then return end
1149  self:compute()
1150  self.model.ui:finishTool()
1151  local edges = {}
1152  for i = 1,#self.edges do
1153    self.edges[i].obj = nil
1154  end
1155
1156  local t = { label = "move graph vertex",
1157	      pno = self.model.pno,
1158	      vno = self.model.vno,
1159	      primary = self.prim,
1160	      original = self.model:page():clone(),
1161	      translation = self.t,
1162	      edges = self.edges,
1163	      undo = revertOriginal,
1164	      redo = apply_node_mode,
1165	    }
1166  self.model:register(t)
1167end
1168
1169function GRAPHTOOL:key(text, modifiers)
1170  if text == "\027" then
1171    self.model.ui:finishTool()
1172    return true
1173  else
1174    return false
1175  end
1176end
1177
1178----------------------------------------------------------------------
1179
1180CHANGEWIDTHTOOL = {}
1181CHANGEWIDTHTOOL.__index = CHANGEWIDTHTOOL
1182
1183function CHANGEWIDTHTOOL:new(model, prim, obj)
1184  local tool = {}
1185  setmetatable(tool, CHANGEWIDTHTOOL)
1186  tool.model = model
1187  tool.prim = prim
1188  tool.obj = obj
1189  tool.pos = obj:matrix() * obj:position()
1190  tool.wid = obj:get("width")
1191  tool.align = obj:get("horizontalalignment")
1192  if tool.align == "right" then
1193    tool.posfactor = -1
1194    tool.dir = -1
1195  elseif tool.align == "hcenter" then
1196    tool.posfactor = -0.5
1197    tool.dir = 1
1198  else
1199    tool.posfactor = 0
1200    tool.dir = 1
1201  end
1202  model.ui:shapeTool(tool)
1203  tool.setColor(1.0, 0, 1.0)
1204  local pos = tool.pos + V(tool.posfactor * tool.wid, 0)
1205  tool.setShape( { boxshape(pos, pos + V(tool.wid, -20)) } )
1206  model.ui:update(false) -- update tool
1207  return tool
1208end
1209
1210function CHANGEWIDTHTOOL:compute()
1211  local w = self.model.ui:pos()
1212  self.nwid = self.wid + self.dir * (w.x - self.v.x)
1213  local pos = self.pos + V(self.posfactor * self.nwid, 0)
1214  self.shape = boxshape(pos, pos + V(self.nwid, -20))
1215end
1216
1217function CHANGEWIDTHTOOL:mouseButton(button, modifiers, press)
1218  if press then
1219    if not self.v then self.v = self.model.ui:pos() end
1220    if self.align == "hcenter" and self.v.x < self.pos.x then self.dir = -1 end
1221  else
1222    self:compute()
1223    self.model.ui:finishTool()
1224    if self.nwid <= 0 then
1225      self.model:warning("The width of a text object should be positive.")
1226      return
1227    end
1228    self.model:setAttributeOfPrimary(self.prim, "width", self.nwid)
1229    self.model:autoRunLatex()
1230  end
1231end
1232
1233function CHANGEWIDTHTOOL:mouseMove()
1234  if self.v then
1235    self:compute()
1236    self.setShape( { self.shape } )
1237    self.model.ui:update(false) -- update tool
1238  end
1239end
1240
1241function CHANGEWIDTHTOOL:key(text, modifiers)
1242  if text == "\027" then
1243    self.model.ui:finishTool()
1244    return true
1245  else
1246    return false
1247  end
1248end
1249
1250function MODEL:action_change_width()
1251  local p = self:page()
1252  local prim = p:primarySelection()
1253  if not prim or p[prim]:type() ~= "text" or not p[prim]:get("minipage") then
1254    self.ui:explain("no selection or not a minipage object")
1255    return
1256  end
1257  CHANGEWIDTHTOOL:new(self, prim, p[prim])
1258end
1259
1260----------------------------------------------------------------------
1261
1262function MODEL:startTransform(mode, withShift)
1263  self:updateCloseSelection(false)
1264  if mode == "stretch" and withShift then mode = "scale" end
1265  self.ui:transformTool(self:page(), self.vno, mode, withShift,
1266			function (m) self:transformation(mode, m) end)
1267end
1268
1269function MODEL:startModeTool(modifiers)
1270  if self.mode == "select" then
1271    self.ui:selectTool(self:page(), self.vno,
1272		       prefs.select_distance, modifiers.shift)
1273  elseif (self.mode == "translate" or self.mode == "stretch"
1274	  or self.mode == "rotate" or self.mode == "shear") then
1275    self:startTransform(self.mode, modifiers.shift)
1276  elseif self.mode == "pan" then
1277    self.ui:panTool(self:page(), self.vno)
1278  elseif self.mode == "shredder" then
1279    self:shredObject()
1280  elseif self.mode == "graph" then
1281    GRAPHTOOL:new(self, modifiers.shift)
1282  elseif self.mode:sub(1,10) == "rectangles" or self.mode == "parallelogram" then
1283    BOXTOOL:new(self, modifiers.shift, self.mode)
1284  elseif self.mode == "splinegons" then
1285    SPLINEGONTOOL:new(self)
1286  elseif (self.mode == "lines" or self.mode == "polygons" or
1287	  self.mode == "splines") then
1288    LINESTOOL:new(self, self.mode)
1289  elseif self.mode:sub(1,6) == "circle" then
1290    CIRCLETOOL:new(self, self.mode)
1291  elseif self.mode:sub(1,3) == "arc" then
1292    ARCTOOL:new(self, self.mode)
1293  elseif self.mode == "ink" then
1294    INKTOOL:new(self)
1295  elseif self.mode == "marks" then
1296    self:createMark()
1297  elseif self.mode == "label" or self.mode == "math" then
1298    self:createText(self.mode, self.ui:pos())
1299  elseif self.mode == "paragraph" then
1300    PARAGRAPHTOOL:new(self)
1301  elseif self.mode == "laser" then
1302    LASERTOOL:new(self)
1303  else
1304    print("start mode tool:", self.mode)
1305  end
1306end
1307
1308local mouse_mappings = {
1309  select = function (m, mo)
1310	     m.ui:selectTool(m:page(), m.vno, prefs.select_distance, mo.shift)
1311	   end,
1312  translate = function (m, mo) m:startTransform("translate", mo.shift) end,
1313  rotate = function (m, mo) m:startTransform("rotate", mo.shift) end,
1314  stretch = function (m, mo) m:startTransform("stretch", mo.shift) end,
1315  scale = function (m, mo) m:startTransform("stretch", true) end,
1316  pan = function (m, mo) m.ui:panTool(m:page(), m.vno) end,
1317  menu = function (m, mo) m:propertiesPopup() end,
1318  shredder = function (m, mo) m:shredObject() end,
1319}
1320
1321function MODEL:mouseButtonAction(button, modifiers)
1322  -- print("Mouse button", button, modifiers.alt, modifiers.control)
1323  if button == 0x81 then button = 1 end -- left double-click
1324  if button == 1 and not modifiers.alt and
1325    not modifiers.control and not modifiers.meta then
1326    self:startModeTool(modifiers)
1327  else
1328    local s = ""
1329    if button == 1 then s = "left"
1330    elseif button == 2 then s = "right"
1331    elseif button == 4 then s = "middle"
1332    elseif button == 8 then s = "button8"
1333    elseif button == 16 then s = "button9"
1334    elseif button == 0 then
1335      -- This is a hack because of the Qt limitation.
1336      -- It really means any other button.
1337      s = "button10"
1338    end
1339    if modifiers.shift then s = s .. "_shift" end
1340    if modifiers.control then s = s .. "_control" end
1341    if modifiers.alt then s = s .. "_alt" end
1342    if modifiers.meta then s = s .. "_meta" end
1343    if modifiers.command then s = s .. "_command" end
1344    local r = mouse[s]
1345    if type(r) == "string" then r = mouse_mappings[r] end
1346    if r then
1347      r(self, modifiers)
1348    else
1349      print("No mouse action defined for " .. s)
1350    end
1351  end
1352end
1353
1354----------------------------------------------------------------------
1355
1356function apply_text_edit(d, data, run_latex)
1357  -- refuse to do anything with empty text
1358  if string.match(d:get("text"), "^%s*$") then return end
1359  if data.obj:text() == d:get("text") and
1360     (not data.size or data.obj:get("textsize") == data.sizes[d:get("size")]) and
1361     (not data.style or data.obj:get(data.stylekind) == data.styles[d:get("style")]) then
1362     return -- hasn't changed since last time
1363  end
1364  local model = data.model
1365  local final = data.obj:clone()
1366  final:setText(d:get("text"))
1367  if data.style then final:set(data.stylekind, data.styles[d:get("style")]) end
1368  if data.size then final:set("textsize", data.sizes[d:get("size")]) end
1369  local t = { label="edit text",
1370	      pno=model.pno,
1371	      vno=model.vno,
1372	      original=data.obj:clone(),
1373	      primary=data.prim,
1374	      final=final,
1375	    }
1376  t.undo = function (t, doc)
1377	     doc[t.pno]:replace(t.primary, t.original)
1378	   end
1379  t.redo = function (t, doc)
1380	     doc[t.pno]:replace(t.primary, t.final)
1381	   end
1382  model:register(t)
1383  -- need to update data.obj for the next run!
1384  data.obj = final
1385  if run_latex then model:runLatex() end
1386end
1387
1388function MODEL:action_edit_text(prim, obj)
1389  local mp = obj:get("minipage")
1390  local stylekind = "labelstyle"
1391  if mp then stylekind = "textstyle" end
1392  local d = ipeui.Dialog(self.ui:win(), "Edit text object")
1393  local data = { model=self,
1394		 stylekind = stylekind,
1395		 styles = self.doc:sheets():allNames(stylekind),
1396		 sizes = self.doc:sheets():allNames("textsize"),
1397		 prim=prim,
1398		 obj=obj,
1399	       }
1400  d:add("label", "label", { label="Edit latex source" }, 1, 1, 1, 2)
1401  d:add("text", "text", { syntax="latex", focus=true,
1402			  spell_check=prefs.spell_check}, 0, 1, 1, 4)
1403  d:addButton("apply", "&Apply",
1404	      function (d) apply_text_edit(d, data, true) end)
1405  addEditorField(d, "text")
1406  d:addButton("ok", "&Ok", "accept")
1407  d:addButton("cancel", "&Cancel", "reject")
1408  d:setStretch("row", 2, 1)
1409  d:setStretch("column", 2, 1)
1410  d:set("text", obj:text())
1411  d:set("ignore-escape", "text", obj:text())
1412  data.style = indexOf(obj:get(stylekind), data.styles)
1413  if data.style then
1414    d:add("style", "combo", data.styles, 1, 3)
1415    d:set("style", data.style)
1416  end
1417  data.size = indexOf(obj:get("textsize"), data.sizes)
1418  if data.size then
1419    d:add("size", "combo", data.sizes, 1, 4)
1420    d:set("size", data.size)
1421  end
1422  if prefs.auto_external_editor then
1423    externalEditor(d, "text")
1424  end
1425  if ((prefs.auto_external_editor and prefs.editor_closes_dialog)
1426    or d:execute(prefs.editor_size)) then
1427    if string.match(d:get("text"), "^%s*$") then return end
1428    apply_text_edit(d, data, self.auto_latex)
1429  end
1430end
1431
1432function MODEL:accept_group_text_edit(prim, group, t, tobj)
1433  local els = group:elements()
1434  els[t] = tobj
1435  local final = ipe.Group(els)
1436  -- copy properties
1437  final:set("pinned", group:get("pinned"))
1438  final:set("transformations", group:get("transformations"))
1439  final:setMatrix(group:matrix())
1440  final:setText(group:text())
1441  final:setClip(group:clip())
1442  final:set("decoration", group:get("decoration"))
1443  local t = { label="edit text in group",
1444	      pno=self.pno,
1445	      vno=self.vno,
1446	      primary=prim,
1447	      original=group:clone(),
1448	      final=final,
1449	    }
1450  t.undo = function (t, doc)
1451	     doc[t.pno]:replace(t.primary, t.original)
1452	   end
1453  t.redo = function (t, doc)
1454	     doc[t.pno]:replace(t.primary, t.final)
1455	   end
1456  self:register(t)
1457  self:autoRunLatex()
1458end
1459
1460function MODEL:action_edit_group_text(prim, obj)
1461  local t = nil
1462  for i = 1,obj:count() do
1463    if obj:elementType(i) == "text" then t = i end
1464  end
1465  if not t then
1466    self:warning("Cannot edit object",
1467		 "Only groups containing text can be edited")
1468    return
1469  end
1470  local tobj = obj:element(t)
1471  local d = ipeui.Dialog(self.ui:win(), "Edit text in group object")
1472  d:add("label", "label", { label="Edit latex source" }, 1, 1, 1, 2)
1473  d:add("text", "text", { syntax="latex", focus=true,
1474			  spell_check=prefs.spell_check}, 0, 1, 1, 4)
1475  addEditorField(d, "text")
1476  d:addButton("ok", "&Ok", "accept")
1477  d:addButton("cancel", "&Cancel", "reject")
1478  d:setStretch("row", 2, 1)
1479  d:setStretch("column", 2, 1)
1480  d:set("text", tobj:text())
1481  d:set("ignore-escape", "text", tobj:text())
1482  if prefs.auto_external_editor then
1483    externalEditor(d, "text")
1484  end
1485  if ((prefs.auto_external_editor and prefs.editor_closes_dialog)
1486    or d:execute(prefs.editor_size)) then
1487    if string.match(d:get("text"), "^%s*$") then return end
1488    local final = tobj:clone()
1489    final:setText(d:get("text"))
1490    self:accept_group_text_edit(prim, obj, t, final)
1491  end
1492end
1493
1494function MODEL:action_edit()
1495  local p = self:page()
1496  local prim = p:primarySelection()
1497  if not prim then self.ui:explain("no selection") return end
1498  local obj = p[prim]
1499  if obj:type() == "text" then
1500    self:action_edit_text(prim, obj)
1501  elseif obj:type() == "path" then
1502    self:action_edit_path(prim, obj)
1503  elseif obj:type() == "group" then
1504    self:action_edit_group_text(prim, obj)
1505  else
1506    self:warning("Cannot edit " .. obj:type() .. " object",
1507		 "Only text objects, path objects, and groups with text can be edited")
1508  end
1509end
1510
1511----------------------------------------------------------------------
1512
1513local function start_group_edit(t, doc)
1514  local p = doc[t.pno]
1515  local layers = p:layers()
1516  local layerOfPrim = p:layerOf(t.primary)
1517  local activeLayer = p:active(t.vno)
1518  local activeIndex = indexOf(activeLayer, layers)
1519  local g = p[t.primary]
1520  local elements = g:elements()
1521  local matrix = g:matrix()
1522  local layer = "EDIT-GROUP"
1523  -- make sure new layer name is unique
1524  while indexOf(layer, layers) do
1525    layer = layer .. "*"
1526  end
1527  local data = "active=" .. activeLayer .. ";primary=" .. layerOfPrim .. ";"
1528  if g:get("decoration") ~= "normal" then
1529    data = data .. "decoration=" .. g:get("decoration"):sub(12) .. ";"
1530  end
1531  data = data .. "locked="
1532  for _,l in ipairs(layers) do
1533    if p:isLocked(l) then data = data .. l .. "," end
1534  end
1535  p:remove(t.primary)
1536  p:addLayer(layer)
1537  p:setLayerData(layer, data)
1538  p:moveLayer(layer, activeIndex + 1)
1539  for _,obj in ipairs(elements) do
1540    p:insert(nil, obj, nil, layer)
1541    p:transform(#p, matrix)
1542  end
1543  p:deselectAll()
1544  p:setActive(t.vno, layer)
1545  p:setVisible(t.vno, layer, true)
1546  for _,l in ipairs(layers) do p:setLocked(l, true) end
1547end
1548
1549local function end_group_edit(t, doc)
1550  local p = doc[t.pno]
1551  local activeLayer = p:active(t.vno)
1552  local data = p:layerData(activeLayer)
1553  local elements = {}
1554  for i, obj, sel, layer in p:objects() do
1555    if layer == activeLayer then
1556      elements[#elements + 1] = obj:clone()
1557    end
1558  end
1559  for i = #p,1,-1 do
1560    if p:layerOf(i) == activeLayer then
1561      p:remove(i)
1562    end
1563  end
1564  p:removeLayer(activeLayer)
1565  local group = ipe.Group(elements)
1566  for _,l in ipairs(p:layers()) do p:setLocked(l, false) end
1567  print("End group edit: ", data)
1568  local newActive = nil
1569  local layerOfGroup = nil
1570  for w in string.gmatch(data, "%w+=[^;]*") do
1571    if w:sub(1, 11) == "decoration=" then
1572      group:set("decoration", "decoration/" .. w:sub(12))
1573    end
1574    if w:sub(1, 7) == "active=" then
1575      newActive = w:sub(8)
1576    end
1577    if w:sub(1, 8) == "primary=" then
1578      layerOfGroup = w:sub(9)
1579    end
1580    if w:sub(1, 7) == "locked=" then
1581      locked = w:sub(8)
1582      for lock in string.gmatch(locked, "[^,]+") do
1583	p:setLocked(lock, true)
1584      end
1585    end
1586  end
1587  -- to be safe, if the page was tampered with
1588  if not newActive then
1589    newActive = p:layers()[1] -- just use first layer of the page
1590  end
1591  if p:isLocked(newActive) then p:setLocked(newActive, false) end
1592  if not layerOfGroup then layerOfGroup = newActive end
1593  p:setActive(t.vno, newActive)
1594  p:insert(nil, group, 1, layerOfGroup)
1595end
1596
1597function MODEL:saction_edit_group()
1598  local p = self:page()
1599  local prim = p:primarySelection()
1600  if p[prim]:type() ~= "group" then
1601    self.ui:explain("primary selection is not a group")
1602    return
1603  end
1604  local t = { label="start group edit",
1605	      pno = self.pno,
1606	      vno = self.vno,
1607	      primary = prim,
1608	      original = p:clone(),
1609	      undo = revertOriginal,
1610	      redo = start_group_edit,
1611	    }
1612  self:register(t)
1613end
1614
1615function MODEL:action_end_group_edit()
1616  local p = self:page()
1617  if (not string.match(p:active(self.vno), "^EDIT%-GROUP")) or
1618    p:countLayers() < 2 then
1619    self:warning("Cannot end group edit",
1620		 "Active layer is not a group edit layer")
1621    return
1622  end
1623  local t = { label="end group edit",
1624	      pno = self.pno,
1625	      vno = self.vno,
1626	      original = p:clone(),
1627	      undo = revertOriginal,
1628	      redo = end_group_edit,
1629	    }
1630  self:register(t)
1631end
1632
1633----------------------------------------------------------------------
1634
1635PASTETOOL = {}
1636PASTETOOL.__index = PASTETOOL
1637
1638function PASTETOOL:new(model, elements, pos)
1639  local tool = {}
1640  _G.setmetatable(tool, PASTETOOL)
1641  tool.model = model
1642  tool.elements = elements
1643  tool.start = model.ui:pos()
1644  if pos then tool.start = pos end
1645  local obj = ipe.Group(elements)
1646  tool.pinned = obj:get("pinned")
1647  model.ui:pasteTool(obj, tool)
1648  tool.setColor(1.0, 0, 0)
1649  tool:computeTranslation()
1650  tool.setMatrix(ipe.Translation(tool.translation))
1651  return tool
1652end
1653
1654function PASTETOOL:computeTranslation()
1655  self.translation = self.model.ui:pos() - self.start
1656  if self.pinned == "horizontal" or self.pinned == "fixed" then
1657    self.translation = V(0, self.translation.y)
1658  end
1659  if self.pinned == "vertical" or self.pinned == "fixed" then
1660    self.translation = V(self.translation.x, 0)
1661  end
1662end
1663
1664function PASTETOOL:mouseButton(button, modifiers, press)
1665  self:computeTranslation()
1666  self.model.ui:finishTool()
1667  local t = { label="paste objects at cursor",
1668	      pno = self.model.pno,
1669	      vno = self.model.vno,
1670	      elements = self.elements,
1671	      layer = self.model:page():active(self.model.vno),
1672	      translation = ipe.Translation(self.translation),
1673	    }
1674  t.undo = function (t, doc)
1675	     local p = doc[t.pno]
1676	     for i = 1,#t.elements do p:remove(#p) end
1677	   end
1678  t.redo = function (t, doc)
1679	     local p = doc[t.pno]
1680	     for i,obj in ipairs(t.elements) do
1681	       p:insert(nil, obj, 2, t.layer)
1682	       p:transform(#p, t.translation)
1683	     end
1684	     p:ensurePrimarySelection()
1685	   end
1686  self.model:page():deselectAll()
1687  self.model:register(t)
1688end
1689
1690function PASTETOOL:mouseMove()
1691  self:computeTranslation()
1692  self.setMatrix(ipe.Translation(self.translation))
1693  self.model.ui:update(false) -- update tool
1694end
1695
1696function PASTETOOL:key(text, modifiers)
1697  if text == "\027" then
1698    self.model.ui:finishTool()
1699    return true
1700  else
1701    return false
1702  end
1703end
1704
1705function MODEL:action_paste_at_cursor()
1706  local data = self.ui:clipboard(true) -- allow bitmap
1707  if not data then
1708    self:warning("Nothing to paste")
1709    return
1710  end
1711  if type(data) == "string" then
1712    if data:sub(1,13) ~= "<ipeselection" then
1713      self:warning("No Ipe selection to paste")
1714      return
1715    end
1716    local pos
1717    local px, py = data:match('^<ipeselection pos="([%d%.]+) ([%d%.]+)"')
1718    if px then pos = V(tonumber(px), tonumber(py)) end
1719    local elements = ipe.Object(data)
1720    if not elements then
1721      self:warning("Could not parse Ipe selection on clipboard")
1722      return
1723    end
1724    PASTETOOL:new(self, elements, pos)
1725  else
1726    -- pasting bitmap
1727    PASTETOOL:new(self, { data }, V(0, 0))
1728  end
1729end
1730
1731----------------------------------------------------------------------
1732
1733LASERTOOL = {}
1734LASERTOOL.__index = LASERTOOL
1735
1736function LASERTOOL:new(model)
1737  local tool = {}
1738  _G.setmetatable(tool, LASERTOOL)
1739  tool.model = model
1740  tool.pos = model.ui:pos()
1741  model.ui:shapeTool(tool)
1742  local p = prefs.laser_pointer.color
1743  tool.setColor(p.r, p.g, p.b)
1744  tool:setPosShape()
1745  return tool
1746end
1747
1748function LASERTOOL:setPosShape()
1749  local radius = prefs.laser_pointer.radius
1750  self.pos = self.model.ui:pos()
1751  local shape = { type="ellipse"; ipe.Matrix(radius, 0, 0, radius, self.pos.x, self.pos.y) }
1752  self.setShape( { shape }, 0, prefs.laser_pointer.pen)
1753  self.model.ui:update(false) -- update tool
1754end
1755
1756function LASERTOOL:mouseButton(button, modifiers, press)
1757  if not press then
1758    self.model.ui:finishTool()
1759  end
1760end
1761
1762function LASERTOOL:mouseMove()
1763  self:setPosShape()
1764end
1765
1766function LASERTOOL:key(text, modifiers)
1767  return false
1768end
1769
1770----------------------------------------------------------------------
1771