CAD-like tool to create, visualize and interactively edit "lofted" shapes in three.js.
Three steps: creating the loft, visualizing it, and editing it.
- The user sketches two or more 2D profiles.
- Each profile is a closed non-crossing polygon with vertices and line segments between them.
- Each profile might contain a different number of vertices.
- The profiles can be positioned in 3D space, but they all must be parallel to each other.
- When the "loft" button is pressed a closed 3D shape is created based on the profiles.
- User can rotate/zoom/pan to see the shape from multiple views.
The four vertices that make up one face might not be planar, in which case that portion of the loft is curved. It's TBD how we render the curved surface. We will start with the simplest possible thing which is just render a quad, such that the two triangles of the quad very roughly depict the curve.
If we decide to go for more visual quality we can play around with tessellating that that quad, or possibly using nurbs patches. However we might decide spending time on better interactive editing is a better use of time compared to improving visual quality.
At a minimum operations should include:
- Rotating one of the sketches along its normal to twist the loft
- Moving a vertex in any of the sketches
- Merging adjacent vertices in a sketch
- Inserting a new vertex in a sketch, splitting that edge into two
Note that rotating sketch after the loft is created should twist the exist loft whereas rotating a sketch and then lofting might produce a different shape based since different vertices might be paired up.
I used ChatGPT to discuss which libraries to use and come up with a general UI strategy.
- Core rendering and interaction: Three.js
- Possibly later on if we want true nurbs rendering: verb
- Typescript + simple build tool: vite
- No front-end framework, it's one big viewport.
We'll start with rendering 1 quad per face, this will very crudely approximate a curve with just two non-planar triangles. From there we can explore tessellating each face into more quads/triangles to better approximate the curved surface. If time permits we could explore true nurbs rendering. However, my assumption based on the wording of the assignment, and the company’s product, is that time is better spent on the UI and interactivity than in getting mathematically perfect shapes.
Show the sketch planes and the lofted shape in 3D, but create and edit sketches in 2D with a normal top-down 2D view. Editing the sketches while the plane is at a 3D angle would be a lot harder without really adding much value.
src/
├── main.ts # App entry point
├── App.ts # Main application class, coordinates components
├── constants.ts # Configuration values (colors, sizes, thresholds)
├── style.css # Application styles
│
├── 2d/
│ ├── Sketch.ts # 2D polygon: vertices, lines, visual meshes
│ ├── SketchEditor.ts # 2D viewport with vertex editing
│ └── tools/
│ ├── EditorTool.ts # Interface for editor tools
│ ├── DrawTool.ts # Tool for drawing polygons vertex by vertex
│ ├── SweepSelection.ts # Rectangle sweep selection tool
│ ├── TransformTool.ts # Tool for translate/scale/rotate transforms
│ ├── VertexTransform.ts # Transform operations on vertex sets
│ └── SelectionHandles.ts # Visual handles for move/scale/rotate
│
├── 3d/
│ ├── Viewport3D.ts # 3D scene with orbit controls and lighting
│ ├── SketchPlane.ts # Sketch positioned at a height in 3D
│ ├── PlaneSelector.ts # Hover/click plane selection
│ ├── PlaneDragger.ts # Drag to move/create/delete planes
│ └── Loft.ts # Renders mesh faces from LoftGeometry
│
├── loft/
│ ├── LoftAlgorithm.ts # Types for loft faces and results
│ ├── LoftGeometry.ts # Computed geometry with segments and faces
│ ├── PerimeterWalkAlgorithm.ts # Perimeter-walk loft algorithm (see below)
│ └── FrozenSegment.ts # Frozen topology for locked segments
│
├── model/
│ └── Model.ts # Central data structure for lofted shapes
│
├── storage/
│ ├── BuildingTypes.ts # TypeScript types for building serialization
│ ├── BuildingSerializer.ts # Serializes Model to/from BuildingData
│ └── BuildingStorage.ts # Client API for saving/loading buildings
│
├── ui/
│ ├── MainToolbar.ts # 3D viewport toolbar (planes, walls, roof, wireframe)
│ ├── SketchToolbar.ts # 2D viewport toolbar (orientation, shape presets)
│ ├── FileMenu.ts # File menu for save/load/new operations
│ └── Minimap.ts # Building structure overview with lock toggles
│
└── util/
├── Geometry.ts # Polygon math (area, winding, triangulation, intersection)
├── Geometry.test.ts # Unit tests for Geometry
├── Bounds.ts # 2D axis-aligned bounding box
├── GridHelper.ts # Grid line rendering
└── HelpPanel.ts # Keyboard shortcut help overlay
The loft algorithm connects two 2D polygon loops into a 3D mesh of quads (and occasionally triangles). The interface is:
type LoftAlgorithm = (
loopA: Vector2[], heightA: number,
loopB: Vector2[], heightB: number
) => { faces: LoftFace[] }The current algorithm (PerimeterWalkAlgorithm.ts) works by "walking" both polygon
perimeters simultaneously, parameterized by arc length:
- Parameterize both loops by cumulative perimeter distance, normalized to [0, 1]
- Walk both loops together, starting at vertex 0 on each
- At each step, compare the parameter of the next vertex on each loop:
- Case 1 (Quad): Both loops reach their next vertex at the same parameter → create a quad, advance both
- Case 2 (Subdivide B): A's next vertex comes first → interpolate a point on B's edge, create quad, advance A only
- Case 3 (Subdivide A): B's next vertex comes first → interpolate a point on A's edge, create quad, advance B only
Key properties:
- Produces mostly quads (good for rendering and further subdivision)
- Handles loops with different vertex counts
- Preserves original polygon shapes exactly (no resampling/distortion)
- Vertices are connected based on relative position along perimeter
When connecting loops with different vertex counts, some edges on one loop may "span" multiple vertices from the other loop. Without intervention, this creates ugly triangle fans radiating from single vertices.
The algorithm uses per-edge adaptive subdivision: for each edge, it counts how many vertices from the other loop fall within that edge's parameter range. If an edge spans N ≥ 3 vertices, N-1 intermediate points are inserted on that edge. Small mismatches (2 vertices per edge) are handled naturally by the perimeter walk without subdivision.
Example: Square edge spanning 5 circle vertices
- Edge parameter range: [0.0, 0.25] (one side of square)
- Circle vertices in range: 5 (at params 0.0, 0.05, 0.10, 0.15, 0.20)
- Insert 4 intermediate points on the square edge
- Result: 5 quads instead of a fan of 5 triangles
This approach is smarter than global subdivision because:
- Only edges that need it get subdivided
- Works correctly even with irregular polygons (dense verts in some areas, sparse in others)
- Produces balanced geometry regardless of total vertex count ratio
Future: Per-segment options
The subdivision behavior can be customized with options:
threshold: minimum vertices to trigger subdivision (default: 3)maxPerEdge: cap on intermediate points per edge (default: unlimited)enabled: turn subdivision on/off entirely
These could be exposed per-segment for multi-floor buildings where different transitions need different tessellation density.