Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ This section is a compact reference for writing OpenGeometry 3.0 code, by hand o
| STEP | `await OpenGeometry.exportStep({ nodes, unit?, upAxis?, name?, timestamp? })` returns `{ text, report }`. `nodes`: bodies, assemblies or `ogId` strings. `unit`: `'millimetre'` (default) or `'metre'`; `upAxis`: `'Z'` (default) or `'Y'`. |
| IFC body | `OpenGeometry.exportIfcBody(solid)` returns the IFC exchange body (type `AnalyticExchangeBodyV2`): `schema_version` 2, world metres, Z up, `representation_type` `'AdvancedBrep'` or `'CSG'` for a solid with a cavity, `entities`, `solids` and `faces`. It refuses a Sheet, a Wire or an assembly. |
| 2D regions | `OpenGeometry.booleanRegions2D(a, b, operation)` and `OpenGeometry.booleanCurvedRegions2D(a, b, operation)` with `'union'`, `'intersection'` or `'subtraction'`. A region is `{ outer, holes }`: an outer ring and a list of hole rings, each ring of `{ x, y, z }` points at one height (type `OffsetRegion`) or of line and arc edges (type `CurvedRegion2D`). Outer rings run clockwise in XZ and holes counter-clockwise. A plain ring is not a region and throws `InvalidParameter`; the tolerance is `OG_REGION_TOLERANCE`. |
| 2D offsets | `OpenGeometry.offsetRingVariable(ring, distances, holes?)` insets a closed ring by one distance per edge, each hole `{ ring, distances }` grown by its own; `OpenGeometry.offsetPolylineRegions(path, width, closed, miterLimit?)` strokes a path. Both return `CurvedRegion2D[]` with exact arcs. `OpenGeometry.offsetPolyline(path, distance, { acuteThresholdDegrees?, bevel?, closed? })` offsets a path to one side and returns `{ edges, bevelled, closed }` (type `OffsetPath2D`). Rings and paths are flat `[x, y, z, ...]` lists; `y` is ignored. |
| 2D offsets | `OpenGeometry.offsetRingVariable(ring, distances, holes?)` insets a closed ring by one distance per edge, each hole `{ ring, distances }` grown by its own; `OpenGeometry.offsetPolylineRegions(path, width, closed, miterLimit?)` strokes a path. Both return `CurvedRegion2D[]` with exact arcs. `OpenGeometry.insetCurvedRegion2D(region, distance)` insets one `CurvedRegion2D` by a distance above zero, holes grown by the same, and returns `CurvedRegion2D[]` of exact lines and arcs: each arc keeps its centre and a reflex corner rounds to an arc of the distance; an inset past the half width is an empty list. `OpenGeometry.offsetPolyline(path, distance, { acuteThresholdDegrees?, bevel?, closed? })` offsets a path to one side and returns `{ edges, bevelled, closed }` (type `OffsetPath2D`). Rings and paths are flat `[x, y, z, ...]` lists; `y` is ignored. |
| `OGError` | Failures throw an `OGError` with `code` (for example `InvalidParameter`, `SharedShape`, `Disposed`), `call`, `message` and `details`. |
| Reset | `OpenGeometry.reset()` frees every body and leaves an empty document. |
| Typed parameters | `OG_PRIMITIVE_PARAMS_<KIND>(params)`, `OG_OPERATION_PARAMS_EXTRUDE(params)`, `OG_OPERATION_PARAMS_SWEEP(params)` and `OG_OPERATION_PARAMS_REVOLVE(params)` return their argument unchanged; they only give the object a type. |
Expand Down
6 changes: 5 additions & 1 deletion main/opengeometry-three/src/opengeometry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { exportIfcBody } from './export/ifc-body';
import { createMark, markStats, type OGMark } from './marks/og-mark';
import { transaction } from './marks/transaction';
import { offsetPolyline, offsetPolylineRegions, offsetRingVariable } from './operations/offsets';
import { booleanCurvedRegions2D, booleanRegions2D } from './operations/regions';
import { booleanCurvedRegions2D, booleanRegions2D, insetCurvedRegion2D } from './operations/regions';
import { resolveHit } from './picking/resolve-hit';
import { flush } from './rendering/geometry/geometry-scheduler';
import { settled } from './rendering/geometry/settle';
Expand Down Expand Up @@ -83,6 +83,10 @@ export class OpenGeometry {
operation: RegionOperation,
): CurvedRegion2D[] { return booleanCurvedRegions2D(a, b, operation); }

static insetCurvedRegion2D(region: CurvedRegion2D, distance: number): CurvedRegion2D[] {
return insetCurvedRegion2D(region, distance);
}

static offsetRingVariable(
ring: readonly number[] | Float64Array,
distances: readonly number[] | Float64Array,
Expand Down
14 changes: 13 additions & 1 deletion main/opengeometry-three/src/operations/regions.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import type { CurvedRegion2D, OffsetRegion, RegionOperation } from '../dto/regions';
import { OGError } from '../errors';
import { call } from '../kernel/kernel-session';
import {
curvedRegionsJson, decodeCurvedRegions, decodeRegions, regionOperation, regionsJson,
curvedRegionsJson, decodeCurvedRegions, decodeRegions, encode, regionOperation, regionsJson,
} from '../world-graph/codec';
import { worldGraph } from '../world-graph/world-graph-client';

const PLANAR = 'OpenGeometry.booleanRegions2D';
const CURVED = 'OpenGeometry.booleanCurvedRegions2D';
const INSET = 'OpenGeometry.insetCurvedRegion2D';

export function booleanRegions2D(
a: readonly OffsetRegion[],
Expand All @@ -30,3 +32,13 @@ export function booleanCurvedRegions2D(
const text = call(CURVED, () => worldGraph().booleanCurvedRegions2D(aJson, bJson, kind));
return decodeCurvedRegions(text, CURVED);
}

export function insetCurvedRegion2D(region: CurvedRegion2D, distance: number): CurvedRegion2D[] {
const given: unknown = region;
if (typeof given !== 'object' || given === null || Array.isArray(given)) {
throw new OGError('InvalidParameter', INSET, 'region must be one { outer, holes } of line and arc edges');
}
if (typeof distance !== 'number') throw new OGError('InvalidParameter', INSET, 'distance must be a number');
const regionJson = encode(region);
return decodeCurvedRegions(call(INSET, () => worldGraph().insetCurvedRegion2D(regionJson, distance)), INSET);
}
37 changes: 37 additions & 0 deletions main/opengeometry-three/tests/node/offsets.test.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { test } from 'node:test';
import { OpenGeometry } from '../../../dist/index.js';
import { boot, failsWith, listen } from './support.mjs';
Expand Down Expand Up @@ -35,6 +36,11 @@ function area(regions) {
- region.holes.reduce((holes, ring) => holes + Math.abs(ringArea(ring)), 0), 0);
}

function length(edge) {
if (edge.kind === 'arc') return edge.radius * Math.abs(edge.sweep_angle);
return Math.hypot(edge.to[0] - edge.from[0], edge.to[1] - edge.from[1]);
}

function arcs(regions) {
return regions.flatMap((region) => [region.outer, ...region.holes].flat()).filter((edge) => edge.kind === 'arc');
}
Expand All @@ -46,6 +52,9 @@ function near(a, b, tolerance = 1e-9) {
const SQUARE_LOT = [[0, 0], [20, 0], [20, 20], [0, 20]];
const HOLE = [[9, 9], [11, 9], [11, 11], [9, 11]];
const L_LOT = [[0, 0], [10, 0], [10, 5], [5, 5], [5, 10], [0, 10]];
const S_BALCONY = JSON.parse(readFileSync(
new URL('../../../opengeometry/tests/fixtures/regions/s-balcony-slab.json', import.meta.url), 'utf8',
));

test('an envelope inset is exact, with each hole corner rounded by its own distance', () => {
const y = 2;
Expand Down Expand Up @@ -83,6 +92,34 @@ test('the ring height is ignored, and a typed array gives the same result as a l
assert.deepEqual(high, low);
});

test('the S balcony slab insets by 1.2 m to the same seven edges, its two arcs on their own centres', () => {
const regions = OpenGeometry.insetCurvedRegion2D(S_BALCONY, 1.2);
const [inset] = regions;
assert(inset && regions.length === 1);
assert.equal(inset.holes.length, 0);
assert.equal(inset.outer.length, S_BALCONY.outer.length);
const rounded = arcs(regions);
assert.equal(rounded.length, 2);
for (const [source, radius] of arcs([S_BALCONY]).map((arc, index) => [arc, [4.8, 7.2][index]])) {
assert(rounded.some((arc) => near(arc.radius, radius)
&& near(arc.center[0], source.center[0]) && near(arc.center[1], source.center[1])));
}
const perimeter = S_BALCONY.outer.reduce((sum, edge) => sum + length(edge), 0);
assert(near(area(regions), area([S_BALCONY]) - 1.2 * perimeter + 4 * 1.2 * 1.2));
});

test('a curved inset past the half width is empty, and a bad region or distance is refused', () => {
assert.deepEqual(OpenGeometry.insetCurvedRegion2D(S_BALCONY, 8), []);
for (const distance of [0, -1.2, Number.NaN, Number.POSITIVE_INFINITY, wire('1.2')]) {
assert.throws(() => OpenGeometry.insetCurvedRegion2D(S_BALCONY, distance), failsWith('InvalidParameter'));
}
for (const region of [wire([S_BALCONY]), wire(null), wire({ ...S_BALCONY, colour: 'red' })]) {
assert.throws(() => OpenGeometry.insetCurvedRegion2D(region, 1.2), failsWith('InvalidParameter'));
}
const open = { outer: S_BALCONY.outer.slice(0, 4), holes: [] };
assert.throws(() => OpenGeometry.insetCurvedRegion2D(open, 1.2), failsWith('UnresolvedIntersection'));
});

test('a routed duct run strokes to width times length, mitred at the default limit of 4 and bevelled at 1', () => {
const run = flat([[0, 0], [4, 0], [4, 3], [8, 3]]);
const regions = OpenGeometry.offsetPolylineRegions(run, 0.3, false, 4);
Expand Down
32 changes: 29 additions & 3 deletions main/opengeometry/src/bindings/geom2d.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
use crate::bindings::{errors, OGWorldGraph};
use crate::geom2d::{
boolean_curved_regions, boolean_oriented_regions, offset_path, offset_polyline_regions,
offset_ring_variable, signed_area2, CurveEdge2, CurveRegion2, OffsetError, OffsetHole,
OffsetOptions, PlanarBooleanOp, Pt2, RingRegion,
boolean_curved_regions, boolean_oriented_regions, inset_curved_region, inset_tolerance,
offset_path, offset_polyline_regions, offset_ring_variable, signed_area2, CurveEdge2,
CurveRegion2, OffsetError, OffsetHole, OffsetOptions, PlanarBooleanOp, Pt2, RingRegion,
};
use crate::world_graph::{ErrorCode, GraphError};
use serde::de::DeserializeOwned;
Expand Down Expand Up @@ -73,6 +73,16 @@ impl OGWorldGraph {
serde_json::to_string(&regions).map_err(errors::serialization)
}

#[wasm_bindgen(js_name = insetCurvedRegion2D)]
pub fn inset_curved_region_2d(
&self,
region_json: &str,
distance: f64,
) -> Result<String, JsValue> {
let regions = curved_inset(region_json, distance).map_err(errors::json)?;
serde_json::to_string(&regions).map_err(errors::serialization)
}

#[wasm_bindgen(js_name = offsetRingVariable)]
pub fn offset_ring_variable(
&self,
Expand Down Expand Up @@ -266,6 +276,22 @@ fn curved_regions(
boolean_curved_regions(&a, &b, operation, REGION_TOLERANCE).map_err(GraphError::from)
}

fn curved_inset(region_json: &str, distance: f64) -> Result<Vec<CurveRegion2>, GraphError> {
if !distance.is_finite() || distance <= 0.0 {
return Err(GraphError::code(
ErrorCode::InvalidParameter,
"inset distance must be a finite number above zero",
));
}
let region: CurveRegion2 = parsed(region_json)?;
check_size(
region.outer.len() + region.holes.iter().map(Vec::len).sum::<usize>(),
"inset region edges",
)?;
let tolerance = inset_tolerance(&region, REGION_TOLERANCE);
inset_curved_region(&region, distance, tolerance).map_err(GraphError::from)
}

fn operation_of(text: &str) -> Result<PlanarBooleanOp, GraphError> {
match text {
"union" => Ok(PlanarBooleanOp::Union),
Expand Down
41 changes: 40 additions & 1 deletion main/opengeometry/src/bindings/world_graph/hierarchy.rs
Original file line number Diff line number Diff line change
@@ -1,20 +1,29 @@
use super::{changed, check_copy, created, serialise, OGWorldGraph};
use crate::bindings::{errors, params};
use crate::brep::{BodyType, BrepEnvelope, GeometryError};
use crate::world_graph::CopyOptions;
use std::sync::Arc;
use wasm_bindgen::prelude::wasm_bindgen;
use wasm_bindgen::JsValue;

#[wasm_bindgen]
impl OGWorldGraph {
pub fn node(&self, og_id: &str) -> Result<String, JsValue> {
let node = self.inner.node(og_id).map_err(errors::json)?;
let mut handles = self.handles.borrow_mut();
if handles.len() > 2 * self.inner.node_count() {
handles.clear();
}
handles.insert((node.handle, node.generation), node.og_id.clone());
drop(handles);
let shape = if node.shape.is_some() {
Some(self.inner.shape(og_id).map_err(errors::json)?)
} else {
None
};
let body_type = shape
.map(|value| value.brep.body_type())
.zip(node.shape.as_deref())
.map(|(value, shape_id)| self.body_type(shape_id, &value.brep))
.transpose()
.map_err(|error| errors::json(error.into()))?
.map(|kind| format!("{kind:?}"));
Expand All @@ -33,6 +42,14 @@ impl OGWorldGraph {

#[wasm_bindgen(js_name = nodeByHandle)]
pub fn node_by_handle(&self, handle: u32, generation: u32) -> Result<String, JsValue> {
let cached = self.handles.borrow().get(&(handle, generation)).cloned();
if let Some(og_id) = cached {
if let Ok(node) = self.inner.node(&og_id) {
if node.handle == handle && node.generation == generation {
return self.node(&og_id);
}
}
}
let node = self
.inner
.node_by_handle(handle, generation)
Expand Down Expand Up @@ -108,3 +125,25 @@ impl OGWorldGraph {
changed(self.inner.dispose(og_id).map_err(errors::json)?)
}
}

impl OGWorldGraph {
fn body_type(
&self,
shape_id: &str,
brep: &Arc<BrepEnvelope>,
) -> Result<BodyType, GeometryError> {
let mut cache = self.body_types.borrow_mut();
if let Some((cached, kind)) = cache.get(shape_id) {
if cached.as_ptr() == Arc::as_ptr(brep) {
return Ok(*kind);
}
}
let kind = brep.body_type()?;
if cache.len() >= self.prune_body_types_at.get() {
cache.retain(|_, (cached, _)| cached.strong_count() > 0);
self.prune_body_types_at.set(2 * cache.len().max(32));
}
cache.insert(shape_id.to_string(), (Arc::downgrade(brep), kind));
Ok(kind)
}
}
10 changes: 9 additions & 1 deletion main/opengeometry/src/bindings/world_graph/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ mod rebuild;
mod shapes;

use crate::bindings::{errors, panic, params};
use crate::brep::Accuracy;
use crate::brep::{Accuracy, BodyType, BrepEnvelope};
use crate::world_graph::{ChangeSet, CopyOptions, CreateOptions, GraphError, MarkId, WorldGraph};
use serde::Serialize;
use std::cell::{Cell, RefCell};
use std::collections::BTreeMap;
use std::sync::Weak;
use wasm_bindgen::prelude::wasm_bindgen;
use wasm_bindgen::JsValue;

Expand Down Expand Up @@ -49,6 +51,9 @@ pub struct OGWorldGraph {
inner: WorldGraph,
marks: BTreeMap<u32, MarkId>,
next_mark: u32,
body_types: RefCell<BTreeMap<String, (Weak<BrepEnvelope>, BodyType)>>,
prune_body_types_at: Cell<usize>,
handles: RefCell<BTreeMap<(u32, u32), String>>,
}

#[wasm_bindgen]
Expand All @@ -62,6 +67,9 @@ impl OGWorldGraph {
inner,
marks: BTreeMap::new(),
next_mark: 0,
body_types: RefCell::new(BTreeMap::new()),
prune_body_types_at: Cell::new(64),
handles: RefCell::new(BTreeMap::new()),
})
}
}
56 changes: 37 additions & 19 deletions main/opengeometry/src/geom2d/boolean2d.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use super::poly2d::{
cross2, dedupe_ring, dot2, point_on_edge_t, ring_inside2, segment_cross_t2, signed_area2,
winding_number2, Edge2, Pt2,
cross2, crossing_margin, dedupe_ring, dot2, edge_box, nearby_boxes, point_on_edge_t,
ring_inside2, segment_cross_t2, signed_area2, winding_number2, Edge2, Pt2,
};
use crate::brep::GeometryError;

Expand Down Expand Up @@ -102,17 +102,29 @@ pub(crate) fn boolean_oriented_regions(
let mut contours = Vec::with_capacity(a.len() + b.len());
contours.extend_from_slice(a);
contours.extend_from_slice(b);
winding_regions_by(&contours, eps, |point| {
let in_a = a
let spans = |rings: &[Vec<Pt2>]| {
rings
.iter()
.map(|ring| winding_number2(point, ring))
.sum::<i32>()
!= 0;
let in_b = b
.map(|ring| {
ring.iter()
.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), point| {
(lo.min(point.z), hi.max(point.z))
})
})
.collect::<Vec<_>>()
};
let (a_spans, b_spans) = (spans(a), spans(b));
let winding = |point: Pt2, rings: &[Vec<Pt2>], spans: &[(f64, f64)]| {
rings
.iter()
.map(|ring| winding_number2(point, ring))
.zip(spans)
.filter(|(_, &(lo, hi))| lo <= point.z && point.z < hi)
.map(|(ring, _)| winding_number2(point, ring))
.sum::<i32>()
!= 0;
};
winding_regions_by(&contours, eps, |point| {
let in_a = winding(point, a, &a_spans) != 0;
let in_b = winding(point, b, &b_spans) != 0;
match operation {
PlanarBooleanOp::Union => in_a || in_b,
PlanarBooleanOp::Intersection => in_a && in_b,
Expand Down Expand Up @@ -159,21 +171,21 @@ pub(crate) fn regions_from_edges_by(
}

fn subdivided_edges(edges: &[Edge2], eps: f64) -> Vec<Edge2> {
let verts: Vec<Pt2> = edges.iter().flat_map(|&(a, b)| [a, b]).collect();
let boxes = edges.iter().map(|&edge| edge_box(edge)).collect::<Vec<_>>();
let near = nearby_boxes(&boxes, crossing_margin(edges.iter().copied(), eps));

let mut split: Vec<Edge2> = Vec::new();
for (i, &(ea, eb)) in edges.iter().enumerate() {
let rx = eb.x - ea.x;
let rz = eb.z - ea.z;
let mut ts: Vec<f64> = vec![0.0, 1.0];
for (j, &(fa, fb)) in edges.iter().enumerate() {
if i != j {
if let Some((_, ta, _)) = segment_cross_t2(ea, eb, fa, fb, eps) {
ts.push(ta);
}
for j in near.of(i) {
let (fa, fb) = edges[j];
if let Some((_, ta, _)) = segment_cross_t2(ea, eb, fa, fb, eps) {
ts.push(ta);
}
}
for &v in &verts {
for v in near.of(i).flat_map(|j| [edges[j].0, edges[j].1]) {
if let Some(t) = point_on_edge_t(ea, eb, v, eps) {
ts.push(t);
}
Expand All @@ -200,7 +212,10 @@ fn subdivided_edges(edges: &[Edge2], eps: f64) -> Vec<Edge2> {

fn boundary_edges(split: &[Edge2], eps: f64, filled: impl Fn(Pt2) -> bool) -> Vec<Edge2> {
let mut boundary: Vec<Edge2> = Vec::new();
for &(a, b) in split {
let boxes = split.iter().map(|&edge| edge_box(edge)).collect::<Vec<_>>();
let most = 1.0e-4;
let near = nearby_boxes(&boxes, 4.0 * most);
for (index, &(a, b)) in split.iter().enumerate() {
let dx = b.x - a.x;
let dz = b.z - a.z;
let len = dx.hypot(dz);
Expand All @@ -211,7 +226,10 @@ fn boundary_edges(split: &[Edge2], eps: f64, filled: impl Fn(Pt2) -> bool) -> Ve
let nz = dx / len;
let mx = (a.x + b.x) / 2.0;
let mz = (a.z + b.z) / 2.0;
let probe = probe_reach(Pt2::new(mx, mz), len, 1.0e-4, split, eps);
let mut nearby = near.of(index).collect::<Vec<_>>();
nearby.insert(nearby.partition_point(|&other| other < index), index);
let nearby = nearby.iter().map(|&other| split[other]).collect::<Vec<_>>();
let probe = probe_reach(Pt2::new(mx, mz), len, most, &nearby, eps);
let left = filled(Pt2::new(mx + nx * probe, mz + nz * probe));
let right = filled(Pt2::new(mx - nx * probe, mz - nz * probe));
if left == right {
Expand Down
3 changes: 2 additions & 1 deletion main/opengeometry/src/geom2d/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,6 @@ pub(super) use offset::{
offset_ring_variable, oriented_ring, pinched, OffsetError, OffsetHole, OffsetOptions,
};
pub(super) use poly2d::{
point_in_ring2, segments_cross2, self_intersects2, signed_area2, winding_number2, Pt2,
crossing_margin, nearby_boxes, point_in_ring2, segments_cross2, self_intersects2, signed_area2,
winding_number2, Nearby, Pt2,
};
Loading
Loading