DSchroer / dslcad

DSLCad is a programming language & interpreter for building 3D models.
https://dslcad.com
GNU Lesser General Public License v2.1
470 stars 14 forks source link

Allow nested scopes to avoid polluting namespace #13

Closed ephetic closed 7 months ago

ephetic commented 1 year ago

e.g.

var size = 10;
var offset = 3;

var table = 
    cube(x=size, y=size, z=size) 
        ->shape translate(x=offset, y=offset, z=offset);

var vase = {
    var size = 3;
    var offset_xy = cube.x/2 + offset;
    var offset_z = cube.z + offset;
    cylinder(radius=size, height=size) 
        ->shape translate(x=offset_xy, y=offset_xy, z=offset_z)
};
DSchroer commented 1 year ago

I like the idea. May need to let things settle in my mind before I can implement it however this does solve the public/private variable concern that I have had.

ephetic commented 1 year ago

The problem I see with it is that it would also be nice to have local function declarations (which ought to be consistent the ./file() interface), but this local scope syntax is close to what a local function declaration might be and which might be more important to work out. What do you think of this:

var offset = 3;

var vase = {
    var size = 3;
    var offset_xy = cube.x/2 + offset;  // NB `offset` could be closure of outer scope or module global
    var offset_z = cube.z + offset;
    cylinder(radius=size, height=size) 
        ->shape translate(x=offset_xy, y=offset_xy, z=offset_z)
};

var my_vase = .vase(size=5);  // leading `.` to mirror `./file()` usage. 

var iife_slab = {
    var size = 3;
    var thickness = 1;
    cube(x=size, y=2*size, z=thickness)
}();  // NB immediately invoked, so `iife_slab` is a shape, not a function.

Alternately, could use fn instead of var to differentiate, the latter being equivalent to the IIFE form.

benthillerkus commented 1 year ago

when I write var c = cube();, what actually is c? Geometry? A function to create geometry?

Tbh I wonder if it would be better to use something like deno core and provide bindings for an existing language like Typescript or LUA.

DSchroer commented 1 year ago

@benthillerkus that depends. In this case cube() produces a raw shape which is a native binding. However ./file() would produce what I call internally a script instance. Its closer to an object in the scripting language sense. They should feel about the same but have some differences type wise.

As for binding there are existing projects doing that approach. You could check out https://ocjs.org/ if that is your preferred way of developing. I wanted the flexibility of having a domain specific language (the DSL of DSLCAD).

ephetic commented 1 year ago

Per #18, how would blocks work with the pipe operator?

As an (immediately invoked function) expression, would you want to do this? If the block/lambda has access to the outer scope, you could just reference it rather than pass it as an argument, further confusing the difference between a block and lambda.

var c = cube() ->shape {
  var shape;
  scale(shape=shape, scale=2);
};

As a function definition, the vars are parameters as with files, so there'd be nothing different about the definition in the previous comment. I can't think of a good reason for using the pipe operator as a weird single argument capture operator in the definition itself, like this:

fn rc = cube() ->shape {
  var shape;
  var scale = 2;
  scale(shape=shape, scale=scale);
};
// now what?  is shape no longer bindable?
rand()
->scale rc();
DSchroer commented 1 year ago

The way I see it right now we need two syntaxes. First an expression syntax as mentioned above for nested scopes:

var c = cube() -> shape {
...
}

Then the statement syntax which would have some more limitations:

fn rc {
  var scale = 2;
  cube() ->shape scale(scale=scale);
}

Note how the fn syntax essentially must go directly into a nested scope. That makes it possible to set it up to always be callable.

DSchroer commented 1 year ago

I think the expression syntax will come first because I don't see any more issues with its implementation. For the statement syntax (function declaration if you want to call it that) there are some open questions in my head:

  1. Does it capture variables from the parent scope? I guess so but that makes it a clojure and changes how it will behave
  2. How do you call it from another script?
ephetic commented 1 year ago
  1. I don't love closures. The options as I see it are:
    • Allow implicit closures (👎)
    • Allow top-level module references (file globals), but not scoped closures (👎)
    • Disallow closures and globals (i.e. pure functions 🥇)
    • Allow explicit closures with capture syntax (🥈 for at least being clear from the signature), e.g.
fn rc | scale, angle | {
  cube() ->shape scale(scale=scale) ->shape rotate(angle=angle);
}
  1. Right now, can you access properties from another file? e.g.
    // constants.ds
    var scale = 10;
    var origin = [0,0,0];
    ...
    // main.ds
    var scale = ./constants.scale;

If so, then the statement usage would mirror it. The expressions could also mirror it, giving rise to maps:

// constants.ds
var scale = 10;
var origin = {
  var x = 0;
  var y = 0;
  var z = 0;
}
var base = { cube(); }
...
// main.ds
var c = ./constants.base() ->shape translate(x=./constants.origin.x);

It's not an elegant map syntax, but it's nice to have and nice to keep usage consistent between files, expressions, and statements. I'd even go so far as to make {} totally equivalent to inlined-files, requiring () to evaluate like a function and . to access module properties (including nested-modules), default argument if you so chose, etc. The only downside I see to this is that I don't get the traditional nested scope like { x = 1; { y = 2; { z = x + y; }}} but I think that's a small price to pay for a simple and consistent mental model. Like the refactoring story would require no semantic changes:

// simple value
var z = 10;  

// computed value
var z = {
  var r = pi() ^ 2;
  cos(radians=r);
}();

// computed value from outer scope
var z = {
  var r = pi() ^ 2;
  cos(radians=r);
}(r=pi() ^ 3);

// reused computed value
var zfn = {
  var r = pi() ^ 2;
  cos(radians=r);
};
var z1 = .zfn();
var z2 = .zfn(r=pi()^3);

// eventually moved into separate file zfn.ds
var z1 = ./zfn();
var z2 = ./zfn(r=pi()^3);
DSchroer commented 1 year ago

TBH I am starting to think we are losing the point here. Lets bring this back to basics. From the original post I see a clear need:

I am comfortable with scope nesting to try to solve this.

Given that we don't want clojure's what does this give you that using a separate file does not? Now I mean really from a "I want to build 3D parts" point of view. Not a "lets design a cool programming language" point of view.

ephetic commented 1 year ago

Solving that problem would be a non-intuitive side effect of all of this that a private keyword would solve much more clearly.

The original ask and subsequent discussion was about tools for keeping code well organized without introducing too many new things to the language (e.g. local file definitions rather than full-fledged functions). This is one of the areas I think OpenSCAD is deficient, whereas host languages are too heavy a solution to.

But they are for sure heavily dependent on my language preferences. I don't like large scopes. I don't like a preponderance of small files. Both consume mental resources I need for working on the problem at hand, at least for me.

DSchroer commented 7 months ago

Hey. Im happy to announce that scopes are finally available.

https://dslcad.com/concepts/#scopes

You can use them in the web editor or custom builds. They will be made available in the pre-built version of the 0.0.4 release.

DSchroer commented 7 months ago

First class functions will probably come soon as a side effect of this work.