Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SVG path with "hole" does not import correctly #1087

Open
kbruneel opened this issue May 12, 2022 · 20 comments
Open

SVG path with "hole" does not import correctly #1087

kbruneel opened this issue May 12, 2022 · 20 comments

Comments

@kbruneel
Copy link

kbruneel commented May 12, 2022

Expected Behavior

When I import an svg path with a hole, the hole is not shown.

Actual Behavior

Hole is not visible.

Steps to Reproduce the Problem

  1. Past the following code in https://openjscad.xyz/
const { extrusions } = require('@jscad/modeling')
const { deserializers } = require('@jscad/io')

const main = () => {

  let s_text = `
  <svg>
    <path
       style="color:#000000;fill:#000000;-inkscape-stroke:none"
       d="m 30,24.390625 c -9.148585,0 -17.605395,4.881781 -22.1796875,12.804687 -4.5742924,7.922907 -4.5742924,17.686469 0,25.609375 C 12.394605,70.727594 20.851415,75.609375 30,75.609375 H 70 C 84.136772,75.609374 95.609375,64.136772 95.609375,50 95.609375,35.863227 84.136773,24.390625 70,24.390625 Z m 0,1.21875 H 70 C 83.477437,25.609375 94.390625,36.522563 94.390625,50 94.390625,63.477436 83.477436,74.390624 70,74.390625 H 30 c -8.714677,0 -16.765708,-4.648181 -21.1230469,-12.195313 -4.3573385,-7.547131 -4.3573385,-16.843493 0,-24.390625 C 13.234292,30.257556 21.285323,25.609375 30,25.609375 Z"
       id="path880" 
    />
  </svg>
  `

  let s = deserializers.svg({ output: "geometry", target: "geom2"}, s_text)
  s = extrusions.extrudeLinear({ height: 10 }, s)
  return s
}


module.exports = { main }

  1. What it should look like:
const { extrusions, transforms, geometries, booleans } = require('@jscad/modeling')
const { deserializers } = require('@jscad/io')

const main = () => {

  let svg_text = `
  <svg>
    <path
       style="color:#000000;fill:#000000;-inkscape-stroke:none"
       d="m 30,24.390625 c -9.148585,0 -17.605395,4.881781 -22.1796875,12.804687 -4.5742924,7.922907 -4.5742924,17.686469 0,25.609375 C 12.394605,70.727594 20.851415,75.609375 30,75.609375 H 70 C 84.136772,75.609374 95.609375,64.136772 95.609375,50 95.609375,35.863227 84.136773,24.390625 70,24.390625 Z m 0,1.21875 H 70 C 83.477437,25.609375 94.390625,36.522563 94.390625,50 94.390625,63.477436 83.477436,74.390624 70,74.390625 H 30 c -8.714677,0 -16.765708,-4.648181 -21.1230469,-12.195313 -4.3573385,-7.547131 -4.3573385,-16.843493 0,-24.390625 C 13.234292,30.257556 21.285323,25.609375 30,25.609375 Z"
       id="path880" 
    />
  </svg>
  `

  let svg_2d = deserializers.svg({ output: "geometry", target: "geom2"}, svg_text)
  
  var svg_extruded  = [];
  var svg_out  = extrusions.extrudeLinear({height:10}, svg_2d[0]);
  var svg_hole = geometries.geom3.invert(extrusions.extrudeLinear({height:10}, svg_2d[1]));
  
  var svg = booleans.subtract(svg_out,svg_hole);

  return svg
}


module.exports = { main }

Specifications

  • Version: 2.9.3
  • Platform:
  • Environment: browser
@platypii
Copy link
Contributor

Yep this looks like an issue. Here is a simplified SVG that shows the same problem:

<svg>
  <path d="m0 0v16h16v-16h-16zm4 4h8v8h-8v-8z" fill="#777"/>
</svg>

@z3dev z3dev changed the title SVG path with "hole" does not export correctly SVG path with "hole" does not import correctly May 12, 2022
@kbruneel
Copy link
Author

@z3dev Any idea what is going wrong where? I can try to solve it, but its not clear to me at this point

Also notice, to get the correct result I need to invert the second geom3 and the subtract it from the first. Because of this I was thinking that the second geom3 is a hole and that I would get the correct result by taking the union of the two geoms3s. Should it work like that, or am I missing something?

@platypii
Copy link
Contributor

If you look at the imported SVG in 2D you see the correct "shape":
hole2d

But when extruded it's not treating it like a hole:
hole3d

The problem is that JSCAD assumes that a "solid" is counter clockwise orientation, and a "hole" is clockwise orientation. But SVG doesn't care about that. Which explains why you needed to invert the hole to make it work.

@platypii
Copy link
Contributor

platypii commented May 13, 2022

Actually after some more investigation I realized I was wrong. The real problem is that the deserializer produces two separate geom2's instead of one. If you "merge" the two objects by concatenating the sides together, it actually works:

const { extrusions } = require('@jscad/modeling')
const { deserializers } = require('@jscad/io')

const main = () => {
  let s_text = `
  <svg>
    <path d="m0 0v16h16v-16h-16zm4 4h8v8h-8v-8z" fill="#81e"/>
  </svg>`

  let s = deserializers.svg({ output: "geometry", target: "geom2"}, s_text)

  // merge sides
  s = {
    ...s[0],
    sides: [ ...s[0].sides, ...s[1].sides ]
  }

  s = extrusions.extrudeLinear({ height: 10 }, s)
  return s
}

module.exports = { main }

hole3d-good

It seems like each svg <path> should produce exactly one geom2.

@z3dev
Copy link
Member

z3dev commented May 13, 2022

@kbruneel this is a common issue when importing SVG paths. #888

SVG is about rendering a raster image (and has rules too) so JSCAD takes a simple approach when converting to useable geometry. Complex, multi-part paths are split into pieces. So, you have to KNOW the contents in order to use results.

If you want to extrude the results then some inspection is required to assure geometries follow proper orientation.

If you have some suggestions then please let us know. We are considering some new functionality to assist users in the conversation of one geometry to another, I.e. path2 to geom2, etc.

@kbruneel
Copy link
Author

@z3dev I understand that and looking into it a bit more now, but my question was about something else. I didn't explain it well, so let me explain it a bit more.

When I import my svg and extrude it I get two geom3s let's call them Solid and Hole.

I get the correct result when I do this:

Result = Subtract(Solid, invert(Hole))

But I expect to also get the same result when I do this

Result = Union(Solid, Hole)

Just because I expect

Union(A,B) = Subtract(A, invert(B))

But this does not give the same result.

Is there something I'm not understanding about union and subtract?

I'm thinking about this because if it would work with union the solution could be as simple as taking the union of all the geometries that are exported from the svg, to get the correct result.

@kbruneel
Copy link
Author

kbruneel commented May 14, 2022

@z3dev @platypii I looked a bit more into the how the insideness of a SVG path is determined (https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/fill-rule) and if I understand it correctly it does look like they also use clockwise and counterclockwise to distinguish solid and hole. At least in the standard setting for the standard setting fill-rule = nonzero. Look at the example in the nonzero section.

@kbruneel
Copy link
Author

kbruneel commented May 14, 2022

@z3dev I did misunderstand Union. It has happened before :)

I had to use intersect instead. Now I get the correct result like this:

const { extrusions, booleans } = require('@jscad/modeling')
const { deserializers } = require('@jscad/io')

const main = () => {

  let s_text = `
  <svg>
    <path
       style="color:#000000;fill:#000000;-inkscape-stroke:none"
       d="m 30,24.390625 c -9.148585,0 -17.605395,4.881781 -22.1796875,12.804687 -4.5742924,7.922907 -4.5742924,17.686469 0,25.609375 C 12.394605,70.727594 20.851415,75.609375 30,75.609375 H 70 C 84.136772,75.609374 95.609375,64.136772 95.609375,50 95.609375,35.863227 84.136773,24.390625 70,24.390625 Z m 0,1.21875 H 70 C 83.477437,25.609375 94.390625,36.522563 94.390625,50 94.390625,63.477436 83.477436,74.390624 70,74.390625 H 30 c -8.714677,0 -16.765708,-4.648181 -21.1230469,-12.195313 -4.3573385,-7.547131 -4.3573385,-16.843493 0,-24.390625 C 13.234292,30.257556 21.285323,25.609375 30,25.609375 Z"
       id="path880" 
    />
  </svg>
  `

  let s = deserializers.svg({ output: "geometry", target: "geom2"}, s_text)
  s = extrusions.extrudeLinear({ height: 10 }, booleans.intersect(s))
  return s
}


module.exports = { main }

I'm wondering if the solution could be that:
When importing a path that contains multiple subpaths, just take the intersection of the subpaths and return this as the geom2 for the path.

@platypii
Copy link
Contributor

Intersect is not the correct solution, in general. It works specifically in this case because the hole essentially defines "all the plane except this hole" and the outer polygon defines the area inside itself, so the intersection is the correct area in this example. But in general there can be holes inside of holes and that wouldn't work using intersection.

nested

I really think the answer is returning a single geom2 for each svg <path> and it should work. We might need some additional checks for counter-clockwise vs clockwise. We also probably need to parse attribute fill-rule and handle nonzero vs evenodd differently.

@z3dev
Copy link
Member

z3dev commented May 14, 2022

I think a utility function that converts multiple path2 into geom2 would be better. Then the utility function is available for general use. Also, the conversion can be complex, as you already pointed out.

And SVG import should just return paths, period.

@z3dev
Copy link
Member

z3dev commented May 14, 2022

Also, those SVG fill rules are not about holes. Those rules are about how to display the internal areas of shapes. It does not equate to booleans, although it may seem that at first.

@kbruneel
Copy link
Author

@z3dev Do you mean that when importing an SVG path, the d-property should be imported as a path without taking into account things like fill, stroke-width, stroke-style, ... Or do you mean all these extra properties of SVG paths should be taken into account when converting an SVG path to JSCAD paths?

@kbruneel
Copy link
Author

@platypii yes indeed you are right.

Intersect may be a quick fix while waiting for a general solution.

@z3dev z3dev self-assigned this Jun 24, 2022
@z3dev
Copy link
Member

z3dev commented Jun 25, 2022

Yep this looks like an issue. Here is a simplified SVG that shows the same problem:

<svg>
  <path d="m0 0v16h16v-16h-16zm4 4h8v8h-8v-8z" fill="#777"/>
</svg>

I'm looking at this further now. So, the resulting code (translation of svg to js) looks like this.

function main(params) {
  let levels = {}
  let paths = {}
  let parts
  levels.l0 = []
  parts = [] // line 5,61
  paths.p01 = geometries.path2.fromPoints({}, [[0, 0]])
  paths.p01 = geometries.path2.appendPoints([[0, -4.5155552]], paths.p01)
  paths.p01 = geometries.path2.appendPoints([[4.5155552, -4.5155552]], paths.p01)
  paths.p01 = geometries.path2.appendPoints([[4.5155552, 0]], paths.p01)
  paths.p01 = geometries.path2.appendPoints([[0, 0]], paths.p01)
  paths.p01 = geometries.path2.close(paths.p01)
  paths.p01 = geometries.geom2.fromPoints(geometries.path2.toPoints(paths.p01))
  parts.push(paths.p01)
  paths.p02 = geometries.path2.fromPoints({}, [[1.1288888, -1.1288888]])
  paths.p02 = geometries.path2.appendPoints([[3.3866663999999997, -1.1288888]], paths.p02)
  paths.p02 = geometries.path2.appendPoints([[3.3866663999999997, -3.3866663999999997]], paths.p02)
  paths.p02 = geometries.path2.appendPoints([[1.1288888, -3.3866663999999997]], paths.p02)
  paths.p02 = geometries.path2.appendPoints([[1.1288888, -1.1288888]], paths.p02)
  paths.p02 = geometries.path2.close(paths.p02)
  paths.p02 = geometries.geom2.fromPoints(geometries.path2.toPoints(paths.p02))
  parts.push(paths.p02)
  paths.p0 = parts  // NEXT
  paths.p0 = colors.colorize([0.4666666666666667,0.4666666666666667,0.4666666666666667,1], paths.p0)
  levels.l0 = levels.l0.concat(paths.p0)

  return levels.l0
}

The sub-paths are being translated into geom2 shapes, and accumulated into a group of 'parts'. All good.

The NEXT step should perform some inspection, and then call a boolean function on the 'parts' to produce the final geom2 shape. There are several IO libraries that create paths, so hopefully the utility function can be re-used.

An even better solution might be... just return the sub-paths as a group of 'parts'. And then call the utility function to convert from a group of paths to geom2 shape.

@z3dev z3dev added the BUG label Jul 2, 2022
@z3dev z3dev added the V2 label Aug 13, 2022
@Papooch
Copy link

Papooch commented Nov 12, 2022

I was trying to convert my OpenSCAD project to JSCAD, and bumped into this issue, too. In OpenSCAD, importing SVG with nontrivial holes just works™. Here's a screenshot with the input file and the result. It's basically just import and extrude with some fancy operations on top.

openscad-svg-import

And here's an attempt to do the same in JSCAD
image

You can see there's also some problem with how the eyes are imported, and when I try to extrude, I just get a solid object:

image

The source SVG is the same. I can assist by providing the sources for both if you'd be interested. Maybe the import algorithm can be ported from OpenSCAD, too, but I'm not really familiar with either codebase.

EDIT: I just tried in Blender and it seems to also understand the SVG correctly
image

@andreasplesch
Copy link
Contributor

andreasplesch commented Apr 26, 2023

@hrgdavor
Copy link
Contributor

It may not be about import alg. jscad uses CW/CCW to diferentiate in vs out for extrude ... because it affects the orientation of vertices during extrusion. Maybe blender and openscad are doing bit more work in the extrude step to recognize holes.

@andreasplesch
Copy link
Contributor

andreasplesch commented Apr 27, 2023

Here is what I more or less understand. Please correct or expand.

  1. svg deserialize() returns a list of geom2 (generated from path2 points)
  2. extrudeLinear() uses extrudeLinearGeom2() to extract the sides from geom2 and generate a slice (a 3d side) from the sides.
  3. calls extrudeFromSlices with caps
  4. extrudeFromSlices generates side polygons and cap polygons using earcut. Earcut has an option for interior hole polygons.

items 3. and 4. seem to work correctly with an appropriate baseSlice with a hole. Perhaps do a simple design to test (slice with a hole).
item 2. probably works correctly, eg. generates an appropriate slice. Test with a simple design (geom2 with hole): Already exists: https://github.com/jscad/OpenJSCAD.org/blob/master/packages/modeling/src/operations/extrusions/extrudeLinear.test.js#L151
item 1. needs work with a utility function to create better geom2

It may be usefuf to add a caps options to extrudeLinearGeom2, passed on to extrudeFromSlides. Turning off caps to make sure that sides are getting properly extruded.

In this sequence, is earcut the only place where the winding order is considered ?

nonzero is the default fill-rule, and is consistent with inner and outer loops having opposite winding order.

@z3dev
Copy link
Member

z3dev commented Apr 27, 2023

@platypii has added a very nice 2D boolean library to V3. And the 2D geometry is now a totally different structure as well; a list of outlines. So, let's try to address this in V3, as the toolset for 2D is far better.

Thoughts?

@andreasplesch
Copy link
Contributor

Just for reference: https://github.com/jscad/OpenJSCAD.org/blob/V3/packages/modeling/src/operations/extrusions/extrudeLinear.test.js#L160
has the extrude geom2 with holes test where the outer outline is ccw and the inner outline is cw.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

6 participants